diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f2c27ac7c3cf..5a9e8bc585119 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -119,18 +119,18 @@ #CC# /x-pack/plugins/beats_management/ @elastic/beats # Canvas -/src/plugins/dashboard/ @elastic/kibana-app -/src/plugins/input_control_vis/ @elastic/kibana-app -/src/plugins/vis_type_markdown/ @elastic/kibana-app +/src/plugins/dashboard/ @elastic/kibana-canvas +/src/plugins/input_control_vis/ @elastic/kibana-canvas +/src/plugins/vis_type_markdown/ @elastic/kibana-canvas /x-pack/plugins/canvas/ @elastic/kibana-canvas -/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-app +/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-canvas /x-pack/test/functional/apps/canvas/ @elastic/kibana-canvas -#CC# /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app -#CC# /src/legacy/core_plugins/input_control_vis @elastic/kibana-app +#CC# /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-canvas +#CC# /src/legacy/core_plugins/input_control_vis @elastic/kibana-canvas #CC# /src/plugins/kibana_react/public/code_editor/ @elastic/kibana-canvas #CC# /x-pack/legacy/plugins/canvas/ @elastic/kibana-canvas -#CC# /x-pack/plugins/dashboard_mode @elastic/kibana-app -#CC# /x-pack/legacy/plugins/dashboard_mode/ @elastic/kibana-app +#CC# /x-pack/plugins/dashboard_mode @elastic/kibana-canvas +#CC# /x-pack/legacy/plugins/dashboard_mode/ @elastic/kibana-canvas # Core UI # Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon diff --git a/.github/ISSUE_TEMPLATE/security_solution_bug_report.md b/.github/ISSUE_TEMPLATE/security_solution_bug_report.md index a7a12a6eb379d..bd7d57c72ea56 100644 --- a/.github/ISSUE_TEMPLATE/security_solution_bug_report.md +++ b/.github/ISSUE_TEMPLATE/security_solution_bug_report.md @@ -2,7 +2,7 @@ name: Bug report for Security Solution about: Help us identify bugs in Elastic Security, SIEM, and Endpoint so we can fix them! title: '[Security Solution]' -labels: 'Team: Security Solution' +labels: Team: SecuritySolution --- **Describe the bug:** diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index a4e9fa32f8a5c..0d8ceefb47e91 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -28,6 +28,8 @@ The following saved objects APIs are available: * <> to resolve errors from the import API +* <> to rotate the encryption key for encrypted saved objects + include::saved-objects/get.asciidoc[] include::saved-objects/bulk_get.asciidoc[] include::saved-objects/find.asciidoc[] @@ -38,3 +40,4 @@ include::saved-objects/delete.asciidoc[] include::saved-objects/export.asciidoc[] include::saved-objects/import.asciidoc[] include::saved-objects/resolve_import_errors.asciidoc[] +include::saved-objects/rotate_encryption_key.asciidoc[] diff --git a/docs/api/saved-objects/rotate_encryption_key.asciidoc b/docs/api/saved-objects/rotate_encryption_key.asciidoc new file mode 100644 index 0000000000000..0a66ed2b4b361 --- /dev/null +++ b/docs/api/saved-objects/rotate_encryption_key.asciidoc @@ -0,0 +1,110 @@ +[role="xpack"] +[[saved-objects-api-rotate-encryption-key]] +=== Rotate encryption key API +++++ +Rotate encryption key +++++ + +experimental[] Rotate the encryption key for encrypted saved objects. + +If a saved object cannot be decrypted using the primary encryption key, then {kib} will attempt to decrypt it using the specified <>. In most of the cases this overhead is negligible, but if you're dealing with a large number of saved objects and experiencing performance issues, you may want to rotate the encryption key. + +[IMPORTANT] +============================================================================ +Bulk key rotation can consume a considerable amount of resources and hence only user with a `superuser` role can trigger it. +============================================================================ + +[[saved-objects-api-rotate-encryption-key-request]] +==== Request + +`POST :/api/encrypted_saved_objects/_rotate_key` + +[[saved-objects-api-rotate-encryption-key-request-query-params]] +==== Query parameters + +`type`:: +(Optional, string) Limits encryption key rotation only to the saved objects with the specified type. By default, {kib} tries to rotate the encryption key for all saved object types that may contain encrypted attributes. + +`batchSize`:: +(Optional, number) Specifies a maximum number of saved objects that {kib} can process in a single batch. Bulk key rotation is an iterative process since {kib} may not be able to fetch and process all required saved objects in one go and splits processing into consequent batches. By default, the batch size is 10000, which is also a maximum allowed value. + +[[saved-objects-api-rotate-encryption-key-response-body]] +==== Response body + +`total`:: +(number) Indicates the total number of _all_ encrypted saved objects (optionally filtered by the requested `type`), regardless of the key {kib} used for encryption. + +`successful`:: +(number) Indicates the total number of _all_ encrypted saved objects (optionally filtered by the requested `type`), regardless of the key {kib} used for encryption. ++ +NOTE: In most cases, `total` will be greater than `successful` even if `failed` is zero. The reason is that {kib} may not need or may not be able to rotate encryption keys for all encrypted saved objects. + +`failed`:: +(number) Indicates the number of the saved objects that were still encrypted with one of the old encryption keys that {kib} failed to re-encrypt with the primary key. + +[[saved-objects-api-rotate-encryption-key-response-codes]] +==== Response code + +`200`:: +Indicates a successful call. + +`400`:: +Indicates that either query parameters are wrong or <> aren't configured. + +`429`:: +Indicates that key rotation is already in progress. + +[[saved-objects-api-rotate-encryption-key-example]] +==== Examples + +[[saved-objects-api-rotate-encryption-key-example-1]] +===== Encryption key rotation with default parameters + +[source,sh] +-------------------------------------------------- +$ curl -X POST /api/encrypted_saved_objects/_rotate_key +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "total": 1000, + "successful": 300, + "failed": 0 +} +-------------------------------------------------- + +The result indicates that the encryption key was successfully rotated for 300 out of 1000 saved objects with encrypted attributes, and 700 of the saved objects either didn't require key rotation, or were encrypted with an unknown encryption key. + +[[saved-objects-api-rotate-encryption-key-example-2]] +===== Encryption key rotation for the specific type with reduce batch size + +[IMPORTANT] +============================================================================ +Default parameters are optimized for speed. Change the parameters only when necessary. However, if you're experiencing any issues with this API, you may want to decrease a batch size or rotate the encryption keys for the specific types only. In this case, you may need to run key rotation multiple times in a row. +============================================================================ + +In this example, key rotation is performed for all saved objects with the `alert` type in batches of 5000. + +[source,sh] +-------------------------------------------------- +$ curl -X POST /api/encrypted_saved_objects/_rotate_key?type=alert&batchSize=5000 +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "total": 100, + "successful": 100, + "failed": 0 +} +-------------------------------------------------- + +The result indicates that the encryption key was successfully rotated for all 100 saved objects with the `alert` type. + diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executor.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executor.md index 2f96ad6e040bd..013624f30b45a 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executor.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.executor.md @@ -41,6 +41,6 @@ export declare class Executor = RecordSignature: ```typescript -run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext): Promise; +run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions): Promise; ``` ## Parameters @@ -19,6 +19,7 @@ run = Recordstring | ExpressionAstExpression | | | input | Input | | | context | ExtraContext | | +| options | ExpressionExecOptions | | Returns: diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md index b8211a6bff27c..18b856b946da4 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md @@ -9,5 +9,5 @@ Starts expression execution and immediately returns `ExecutionContract` instance Signature: ```typescript -execute: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => ExecutionContract; +execute: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => ExecutionContract; ``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.md index 34bf16c121326..def572abead22 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.md @@ -16,12 +16,12 @@ export interface ExpressionsServiceStart | Property | Type | Description | | --- | --- | --- | -| [execute](./kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md) | <Input = unknown, Output = unknown, ExtraContext extends Record<string, unknown> = Record<string, unknown>>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => ExecutionContract<ExtraContext, Input, Output> | Starts expression execution and immediately returns ExecutionContract instance that tracks the progress of the execution and can be used to interact with the execution. | +| [execute](./kibana-plugin-plugins-expressions-public.expressionsservicestart.execute.md) | <Input = unknown, Output = unknown, ExtraContext extends Record<string, unknown> = Record<string, unknown>>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => ExecutionContract<ExtraContext, Input, Output> | Starts expression execution and immediately returns ExecutionContract instance that tracks the progress of the execution and can be used to interact with the execution. | | [fork](./kibana-plugin-plugins-expressions-public.expressionsservicestart.fork.md) | () => ExpressionsService | Create a new instance of ExpressionsService. The new instance inherits all state of the original ExpressionsService, including all expression types, expression functions and context. Also, all new types and functions registered in the original services AFTER the forking event will be available in the forked instance. However, all new types and functions registered in the forked instances will NOT be available to the original service. | | [getFunction](./kibana-plugin-plugins-expressions-public.expressionsservicestart.getfunction.md) | (name: string) => ReturnType<Executor['getFunction']> | Get a registered ExpressionFunction by its name, which was registered using the registerFunction method. The returned ExpressionFunction instance is an internal representation of the function in Expressions service - do not mutate that object. | | [getRenderer](./kibana-plugin-plugins-expressions-public.expressionsservicestart.getrenderer.md) | (name: string) => ReturnType<ExpressionRendererRegistry['get']> | Get a registered ExpressionRenderer by its name, which was registered using the registerRenderer method. The returned ExpressionRenderer instance is an internal representation of the renderer in Expressions service - do not mutate that object. | | [getType](./kibana-plugin-plugins-expressions-public.expressionsservicestart.gettype.md) | (name: string) => ReturnType<Executor['getType']> | Get a registered ExpressionType by its name, which was registered using the registerType method. The returned ExpressionType instance is an internal representation of the type in Expressions service - do not mutate that object. | -| [run](./kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md) | <Input, Output, ExtraContext extends Record<string, unknown> = Record<string, unknown>>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => Promise<Output> | Executes expression string or a parsed expression AST and immediately returns the result.Below example will execute sleep 100 | clog expression with 123 initial input to the first function. +| [run](./kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md) | <Input, Output, ExtraContext extends Record<string, unknown> = Record<string, unknown>>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => Promise<Output> | Executes expression string or a parsed expression AST and immediately returns the result.Below example will execute sleep 100 | clog expression with 123 initial input to the first function. ```ts expressions.run('sleep 100 | clog', 123); diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md index 578c583624ad0..d717af51a00fa 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicestart.run.md @@ -24,5 +24,5 @@ expressions.run('...', null, { elasticsearchClient }); Signature: ```typescript -run: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => Promise; +run: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => Promise; ``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md new file mode 100644 index 0000000000000..b27246449cc7c --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IExpressionLoaderParams](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) > [debug](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md) + +## IExpressionLoaderParams.debug property + +Signature: + +```typescript +debug?: boolean; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md index b8a174f93fb99..d6e02350bae3f 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md @@ -17,6 +17,7 @@ export interface IExpressionLoaderParams | [context](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.context.md) | ExpressionValue | | | [customFunctions](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.customfunctions.md) | [] | | | [customRenderers](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.customrenderers.md) | [] | | +| [debug](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md) | boolean | | | [disableCaching](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.disablecaching.md) | boolean | | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.inspectoradapters.md) | Adapters | | | [onRenderError](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.onrendererror.md) | RenderErrorHandlerFnType | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executor.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executor.md index ec4e0bdcc4569..46ad60ae07126 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executor.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.executor.md @@ -41,6 +41,6 @@ export declare class Executor = RecordSignature: ```typescript -run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext): Promise; +run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions): Promise; ``` ## Parameters @@ -19,6 +19,7 @@ run = Recordstring | ExpressionAstExpression | | | input | Input | | | context | ExtraContext | | +| options | ExpressionExecOptions | | Returns: diff --git a/docs/plugins/known-plugins.asciidoc b/docs/plugins/known-plugins.asciidoc index 8fc2b7381de83..7b24de42d8e1c 100644 --- a/docs/plugins/known-plugins.asciidoc +++ b/docs/plugins/known-plugins.asciidoc @@ -14,7 +14,6 @@ This list of plugins is not guaranteed to work on your version of Kibana. Instea * https://github.com/sivasamyk/logtrail[LogTrail] - View, analyze, search and tail log events in realtime with a developer/sysadmin friendly interface * https://github.com/wtakase/kibana-own-home[Own Home] (wtakase) - enables multi-tenancy * https://github.com/asileon/kibana_shard_allocation[Shard Allocation] (asileon) - visualize elasticsearch shard allocation -* https://github.com/samtecspg/conveyor[Conveyor] - Simple (GUI) interface for importing data into Elasticsearch. * https://github.com/wazuh/wazuh-kibana-app[Wazuh] - Wazuh provides host-based security visibility using lightweight multi-platform agents. * https://github.com/TrumanDu/indices_view[Indices View] - View indices related information. * https://github.com/johtani/analyze-api-ui-plugin[Analyze UI] (johtani) - UI for elasticsearch _analyze API diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 00e5f973f7d87..c743aa43fab05 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -239,3 +239,26 @@ The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', ============ |=== + +[float] +[[security-encrypted-saved-objects-settings]] +==== Encrypted saved objects settings + +These settings control the encryption of saved objects with sensitive data. For more details, refer to <>. + +[IMPORTANT] +============ +In high-availability deployments, make sure you use the same encryption and decryption keys for all instances of {kib}. Although the keys can be specified in clear text in `kibana.yml`, it's recommended to store them securely in the <>. +============ + +[cols="2*<"] +|=== +| [[xpack-encryptedSavedObjects-encryptionKey]] `xpack.encryptedSavedObjects.` +`encryptionKey` +| An arbitrary string of at least 32 characters that is used to encrypt sensitive properties of saved objects before they're stored in {es}. If not set, {kib} will generate a random key on startup, but certain features won't be available until you set the encryption key explicitly. + +| [[xpack-encryptedSavedObjects-keyRotation-decryptionOnlyKeys]] `xpack.encryptedSavedObjects.` +`keyRotation.decryptionOnlyKeys` +| An optional list of previously used encryption keys. Like <>, these must be at least 32 characters in length. {kib} doesn't use these keys for encryption, but may still require them to decrypt some existing saved objects. Use this setting if you wish to change your encryption key, but don't want to lose access to saved objects that were previously encrypted with a different key. + +|=== \ No newline at end of file diff --git a/docs/user/security/secure-saved-objects.asciidoc b/docs/user/security/secure-saved-objects.asciidoc new file mode 100644 index 0000000000000..3b15a576500f1 --- /dev/null +++ b/docs/user/security/secure-saved-objects.asciidoc @@ -0,0 +1,47 @@ +[role="xpack"] +[[xpack-security-secure-saved-objects]] +=== Secure saved objects + +{kib} stores entities such as dashboards, visualizations, alerts, actions, and advanced settings as saved objects, which are kept in a dedicated, internal {es} index. If such an object includes sensitive information, for example a PagerDuty integration key or email server credentials used by the alert action, {kib} encrypts it and makes sure it cannot be accidentally leaked or tampered with. + +Encrypting sensitive information means that a malicious party with access to the {kib} internal indices won't be able to extract that information without also knowing the encryption key. + +Example `kibana.yml`: + +[source,yaml] +-------------------------------------------------------------------------------- +xpack.encryptedSavedObjects: + encryptionKey: "min-32-byte-long-strong-encryption-key" +-------------------------------------------------------------------------------- + +[IMPORTANT] +============================================================================ +If you don't specify an encryption key, {kib} automatically generates a random key at startup. Every time you restart {kib}, it uses a new ephemeral encryption key and is unable to decrypt saved objects encrypted with another key. To prevent data loss, {kib} might disable features that rely on this encryption until you explicitly set an encryption key. +============================================================================ + +[[encryption-key-rotation]] +==== Encryption key rotation + +Many policies and best practices stipulate that encryption keys should be periodically rotated to decrease the amount of content encrypted with one key and therefore limit the potential damage if the key is compromised. {kib} allows you to rotate encryption keys whenever there is a need. + +When you change an encryption key, be sure to keep the old one for some time. Although {kib} only uses a new encryption key to encrypt all new and updated data, it still may need the old one to decrypt data that was encrypted using the old key. It's possible to have multiple old keys used only for decryption. {kib} doesn't automatically re-encrypt existing saved objects with the new encryption key. Re-encryption only happens when you update existing object or use the <>. + +Here is how your `kibana.yml` might look if you use key rotation functionality: + +[source,yaml] +-------------------------------------------------------------------------------- +xpack.encryptedSavedObjects: + encryptionKey: "min-32-byte-long-NEW-encryption-key" <1> + keyRotation: + decryptionOnlyKeys: ["min-32-byte-long-OLD#1-encryption-key", "min-32-byte-long-OLD#2-encryption-key"] <2> +-------------------------------------------------------------------------------- + +<1> The encryption key {kib} will use to encrypt all new or updated saved objects. This is known as the primary encryption key. +<2> A list of encryption keys {kib} will try to use to decrypt existing saved objects if decryption with the primary encryption key isn't possible. These keys are known as the decryption-only or secondary encryption keys. + +[NOTE] +============================================================================ +You might also leverage this functionality if multiple {kib} instances connected to the same {es} cluster use different encryption keys. In this case, you might have a mix of saved objects encrypted with different keys, and every {kib} instance can only deal with a specific subset of objects. To fix this, you must choose a single primary encryption key for `xpack.encryptedSavedObjects.encryptionKey`, move all other encryption keys to `xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys`, and sync this configuration across all {kib} instances. +============================================================================ + +At some point, you might want to dispose of old encryption keys completely. Make sure there are no saved objects that {kib} encrypted with these encryption keys. You can use the <> to determine which existing saved objects require decryption-only keys and re-encrypt them with the primary key. diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 0f02279eaf1f3..e7bd297a3ebb5 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -129,3 +129,4 @@ include::securing-communications/elasticsearch-mutual-tls.asciidoc[] include::audit-logging.asciidoc[] include::access-agreement.asciidoc[] include::session-management.asciidoc[] +include::secure-saved-objects.asciidoc[] diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 0d08f6f3007b0..4d54d4831698b 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -242,11 +242,17 @@ export class ApplicationService { appId, { path, state, replace = false }: NavigateToAppOptions = {} ) => { - if (await this.shouldNavigate(overlays)) { + const currentAppId = this.currentAppId$.value; + const navigatingToSameApp = currentAppId === appId; + const shouldNavigate = navigatingToSameApp ? true : await this.shouldNavigate(overlays); + + if (shouldNavigate) { if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } - this.appInternalStates.delete(this.currentAppId$.value!); + if (!navigatingToSameApp) { + this.appInternalStates.delete(this.currentAppId$.value!); + } this.navigate!(getAppUrl(availableMounters, appId, path), state, replace); this.currentAppId$.next(appId); } diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index d28486928b7e2..82933576bc493 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -258,6 +258,34 @@ describe('ApplicationService', () => { expect(history.entries.length).toEqual(2); expect(history.entries[1].pathname).toEqual('/app/app1'); }); + + it('does not trigger navigation check if navigating to the current app', async () => { + startDeps.overlays.openConfirm.mockResolvedValue(false); + + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave((actions) => actions.confirm('confirmation-message', 'confirmation-title')); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app1', { path: '/internal-path' }); + }); + + expect(startDeps.overlays.openConfirm).not.toHaveBeenCalled(); + expect(history.entries.length).toEqual(3); + expect(history.entries[2].pathname).toEqual('/app/app1/internal-path'); + }); }); describe('registering action menus', () => { @@ -331,6 +359,48 @@ describe('ApplicationService', () => { expect(await getValue(currentActionMenu$)).toBe(mounter2); }); + it('does not update the observable value when navigating to the current app', async () => { + const { register } = service.setup(setupDeps); + + let initialMount = true; + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + if (initialMount) { + setHeaderActionMenu(mounter1); + initialMount = false; + } + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + let mountedMenuCount = 0; + currentActionMenu$.subscribe(() => { + mountedMenuCount++; + }); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + // there is an initial 'undefined' emission + expect(mountedMenuCount).toBe(2); + }); + it('updates the observable value to undefined when switching to an application without action menu', async () => { const { register } = service.setup(setupDeps); diff --git a/src/core/public/chrome/ui/__snapshots__/loading_indicator.test.tsx.snap b/src/core/public/chrome/ui/__snapshots__/loading_indicator.test.tsx.snap index e6bf7e898d8c4..10e6e9befe4f9 100644 --- a/src/core/public/chrome/ui/__snapshots__/loading_indicator.test.tsx.snap +++ b/src/core/public/chrome/ui/__snapshots__/loading_indicator.test.tsx.snap @@ -1,19 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`kbnLoadingIndicator is hidden by default 1`] = ` - `; exports[`kbnLoadingIndicator is visible when loadingCount is > 0 1`] = ` - `; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index e733c7fda5d5a..5e563c4061093 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -1715,17 +1715,10 @@ exports[`Header renders 1`] = ` } } href="/" - navLinks$={ + loadingCount$={ BehaviorSubject { "_isScalar": false, - "_value": Array [ - Object { - "baseUrl": "", - "href": "", - "id": "kibana", - "title": "kibana", - }, - ], + "_value": 0, "closed": false, "hasError": false, "isStopped": false, @@ -1767,6 +1760,25 @@ exports[`Header renders 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + ], + "thrownError": null, + } + } + navLinks$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [ + Object { + "baseUrl": "", + "href": "", + "id": "kibana", + "title": "kibana", + }, + ], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ Subscriber { "_parentOrParents": null, "_subscriptions": Array [ @@ -1804,21 +1816,6 @@ exports[`Header renders 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - ], - "thrownError": null, - } - } - navigateToApp={[MockFunction]} - />, - , ], }, @@ -2821,17 +2818,10 @@ exports[`Header renders 1`] = ` } } href="/" - navLinks$={ + loadingCount$={ BehaviorSubject { "_isScalar": false, - "_value": Array [ - Object { - "baseUrl": "", - "href": "", - "id": "kibana", - "title": "kibana", - }, - ], + "_value": 0, "closed": false, "hasError": false, "isStopped": false, @@ -2873,6 +2863,25 @@ exports[`Header renders 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + ], + "thrownError": null, + } + } + navLinks$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [ + Object { + "baseUrl": "", + "href": "", + "id": "kibana", + "title": "kibana", + }, + ], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ Subscriber { "_parentOrParents": null, "_subscriptions": Array [ @@ -2910,66 +2919,6 @@ exports[`Header renders 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - ], - "thrownError": null, - } - } - navigateToApp={[MockFunction]} - > - - - - - - -
- - - + +
+ + + - - + className="chrHeaderLogo__mark" + > + + + Elastic + + + + + +
diff --git a/src/core/public/chrome/ui/header/elastic_mark.tsx b/src/core/public/chrome/ui/header/elastic_mark.tsx new file mode 100644 index 0000000000000..e4456e9b751f3 --- /dev/null +++ b/src/core/public/chrome/ui/header/elastic_mark.tsx @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { HTMLAttributes } from 'react'; + +export const ElasticMark = ({ ...props }: HTMLAttributes) => ( + + Elastic + + +); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index d0b39e362ecb7..7089ec1087271 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -112,8 +112,8 @@ export function Header({ forceNavigation$={observables.forceAppSwitcherNavigation$} navLinks$={observables.navLinks$} navigateToApp={application.navigateToApp} + loadingCount$={observables.loadingCount$} />, - , ], borders: 'none', }, diff --git a/src/core/public/chrome/ui/header/header_logo.scss b/src/core/public/chrome/ui/header/header_logo.scss new file mode 100644 index 0000000000000..f75fd9cfa2466 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_logo.scss @@ -0,0 +1,4 @@ +.chrHeaderLogo__mark { + margin-left: $euiSizeS; + fill: $euiColorGhost; +} diff --git a/src/core/public/chrome/ui/header/header_logo.tsx b/src/core/public/chrome/ui/header/header_logo.tsx index 83e0c52ab3f3a..df961ebb0983f 100644 --- a/src/core/public/chrome/ui/header/header_logo.tsx +++ b/src/core/public/chrome/ui/header/header_logo.tsx @@ -17,13 +17,16 @@ * under the License. */ -import { EuiHeaderLogo } from '@elastic/eui'; +import './header_logo.scss'; import { i18n } from '@kbn/i18n'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; import Url from 'url'; import { ChromeNavLink } from '../..'; +import { ElasticMark } from './elastic_mark'; +import { HttpStart } from '../../../http'; +import { LoadingIndicator } from '../loading_indicator'; function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { let current = element; @@ -90,23 +93,25 @@ interface Props { navLinks$: Observable; forceNavigation$: Observable; navigateToApp: (appId: string) => void; + loadingCount$?: ReturnType; } -export function HeaderLogo({ href, navigateToApp, ...observables }: Props) { +export function HeaderLogo({ href, navigateToApp, loadingCount$, ...observables }: Props) { const forceNavigation = useObservable(observables.forceNavigation$, false); const navLinks = useObservable(observables.navLinks$, []); return ( - onClick(e, forceNavigation, navLinks, navigateToApp)} + className="euiHeaderLogo" href={href} + data-test-subj="logo" aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel', { - defaultMessage: 'Go to home page', + defaultMessage: 'Elastic home', })} > - Elastic - + + + ); } diff --git a/src/core/public/chrome/ui/loading_indicator.test.tsx b/src/core/public/chrome/ui/loading_indicator.test.tsx index ff56ca668ae02..2d45a3d079616 100644 --- a/src/core/public/chrome/ui/loading_indicator.test.tsx +++ b/src/core/public/chrome/ui/loading_indicator.test.tsx @@ -32,7 +32,10 @@ describe('kbnLoadingIndicator', () => { it('is visible when loadingCount is > 0', () => { const wrapper = shallow(); - expect(wrapper.prop('data-test-subj')).toBe('globalLoadingIndicator'); + // Pause the check beyond the 250ms delay that it has + setTimeout(() => { + expect(wrapper.prop('data-test-subj')).toBe('globalLoadingIndicator'); + }, 300); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/core/public/chrome/ui/loading_indicator.tsx b/src/core/public/chrome/ui/loading_indicator.tsx index ca3e95f722ec5..25ec52e8dbb58 100644 --- a/src/core/public/chrome/ui/loading_indicator.tsx +++ b/src/core/public/chrome/ui/loading_indicator.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { EuiLoadingSpinner, EuiProgress } from '@elastic/eui'; +import { EuiLoadingSpinner, EuiProgress, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import classNames from 'classnames'; @@ -39,16 +39,26 @@ export class LoadingIndicator extends React.Component { - this.setState({ - visible: count > 0, - }); + if (this.increment > 1) { + clearTimeout(this.timer); + } + this.increment += this.increment; + this.timer = setTimeout(() => { + this.setState({ + visible: count > 0, + }); + }, 250); }); } componentWillUnmount() { if (this.loadingCountSubscription) { + clearTimeout(this.timer); this.loadingCountSubscription.unsubscribe(); this.loadingCountSubscription = undefined; } @@ -67,13 +77,27 @@ export class LoadingIndicator extends React.Component + ) : ( + + ); + + return !this.props.showAsBar ? ( + logo ) : ( { it('should create new darker colors when input is greater than 72', () => { expect(createColorPalette(num3)[72]).not.toEqual(seedColors[0]); }); + + it('should create new colors and convert them correctly', () => { + expect(createColorPalette(num3)[72]).toEqual('#404ABF'); + }); }); diff --git a/src/plugins/data/public/query/query_string/query_string_manager.test.ts b/src/plugins/data/public/query/query_string/query_string_manager.test.ts new file mode 100644 index 0000000000000..aa1556480452a --- /dev/null +++ b/src/plugins/data/public/query/query_string/query_string_manager.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { QueryStringManager } from './query_string_manager'; +import { Storage } from '../../../../kibana_utils/public/storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { coreMock } from '../../../../../core/public/mocks'; +import { Query } from '../../../common/query'; + +describe('QueryStringManager', () => { + let service: QueryStringManager; + + beforeEach(() => { + service = new QueryStringManager( + new Storage(new StubBrowserStorage()), + coreMock.createSetup().uiSettings + ); + }); + + test('getUpdates$ is a cold emits only after query changes', () => { + const obs$ = service.getUpdates$(); + const emittedValues: Query[] = []; + obs$.subscribe((v) => { + emittedValues.push(v); + }); + expect(emittedValues).toHaveLength(0); + + const newQuery = { query: 'new query', language: 'kquery' }; + service.setQuery(newQuery); + expect(emittedValues).toHaveLength(1); + expect(emittedValues[0]).toEqual(newQuery); + + service.setQuery({ ...newQuery }); + expect(emittedValues).toHaveLength(1); + }); +}); diff --git a/src/plugins/data/public/query/query_string/query_string_manager.ts b/src/plugins/data/public/query/query_string/query_string_manager.ts index bd02830f4aed8..50732c99a62d9 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.ts @@ -17,8 +17,8 @@ * under the License. */ -import _ from 'lodash'; import { BehaviorSubject } from 'rxjs'; +import { skip } from 'rxjs/operators'; import { CoreStart } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { Query, UI_SETTINGS } from '../../../common'; @@ -61,7 +61,7 @@ export class QueryStringManager { } public getUpdates$ = () => { - return this.query$.asObservable(); + return this.query$.asObservable().pipe(skip(1)); }; public getQuery = (): Query => { diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 659b8dede564c..612cedb7780bd 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -193,7 +193,7 @@ app.directive('discoverApp', function () { function discoverController($element, $route, $scope, $timeout, $window, Promise, uiCapabilities) { const { isDefault: isDefaultType } = indexPatternsUtils; const subscriptions = new Subscription(); - const $fetchObservable = new Subject(); + const refetch$ = new Subject(); let inspectorRequest; const savedSearch = $route.current.locals.savedObjects.savedSearch; $scope.searchSource = savedSearch.searchSource; @@ -267,7 +267,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise ); if (changes.length) { - $fetchObservable.next(); + refetch$.next(); } }); } @@ -634,12 +634,18 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise const init = _.once(() => { $scope.updateDataSource().then(async () => { - const searchBarChanges = merge(data.query.state$, $fetchObservable).pipe(debounceTime(100)); + const fetch$ = merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getAutoRefreshFetch$(), + data.query.queryString.getUpdates$() + ).pipe(debounceTime(100)); subscriptions.add( subscribeWithScope( $scope, - searchBarChanges, + fetch$, { next: $scope.fetch, }, @@ -718,7 +724,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise init.complete = true; if (shouldSearchOnPageLoad()) { - $fetchObservable.next(); + refetch$.next(); } }); }); @@ -816,7 +822,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise $scope.handleRefresh = function (_payload, isUpdate) { if (isUpdate === false) { - $fetchObservable.next(); + refetch$.next(); } }; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts new file mode 100644 index 0000000000000..1c3643898dd1d --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ApiError } from '@elastic/elasticsearch'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { IKibanaResponse, KibanaResponseFactory } from 'kibana/server'; + +interface EsErrorHandlerParams { + error: ApiError; + response: KibanaResponseFactory; +} + +/* + * For errors returned by the new elasticsearch js client. + */ +export const handleEsError = ({ error, response }: EsErrorHandlerParams): IKibanaResponse => { + // error.name is slightly better in terms of performance, since all errors now have name property + if (error.name === 'ResponseError') { + const { statusCode, body } = error as ResponseError; + return response.customError({ + statusCode, + body: { message: body.error?.reason }, + }); + } + // Case: default + return response.internalError({ body: error }); +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts index 0d025442f4a92..484dc17868ab0 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts @@ -18,3 +18,4 @@ */ export { isEsError } from './is_es_error'; +export { handleEsError } from './handle_es_error'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/is_es_error.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/is_es_error.ts index 1e212307ca1cc..80a53aac328a4 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/is_es_error.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/is_es_error.ts @@ -25,6 +25,10 @@ interface RequestError extends Error { statusCode?: number; } +/* + * @deprecated + * Only works with legacy elasticsearch js client errors and will be removed after 7.x last + */ export function isEsError(err: RequestError) { const isInstanceOfEsError = err instanceof esErrorsParent; const hasStatusCode = Boolean(err.statusCode); diff --git a/src/plugins/es_ui_shared/server/errors/index.ts b/src/plugins/es_ui_shared/server/errors/index.ts index c18374cd9ec31..532e02774ff50 100644 --- a/src/plugins/es_ui_shared/server/errors/index.ts +++ b/src/plugins/es_ui_shared/server/errors/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { isEsError } from '../../__packages_do_not_import__/errors'; +export { isEsError, handleEsError } from '../../__packages_do_not_import__/errors'; diff --git a/src/plugins/es_ui_shared/server/index.ts b/src/plugins/es_ui_shared/server/index.ts index 0118bbda53262..b2c9c85d956ba 100644 --- a/src/plugins/es_ui_shared/server/index.ts +++ b/src/plugins/es_ui_shared/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -export { isEsError } from './errors'; +export { isEsError, handleEsError } from './errors'; /** dummy plugin*/ export function plugin() { diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 28aae8c8f4834..fd7f5808f0340 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -170,8 +170,13 @@ export class Executor = Record = Record - >(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) { - const execution = this.createExecution(ast, context); + >( + ast: string | ExpressionAstExpression, + input: Input, + context?: ExtraContext, + options?: ExpressionExecOptions + ) { + const execution = this.createExecution(ast, context, options); execution.start(input); return (await execution.result) as Output; } diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index 4a87fd9e7f331..3d0fb968e8a3a 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Executor } from '../executor'; +import { Executor, ExpressionExecOptions } from '../executor'; import { AnyExpressionRenderDefinition, ExpressionRendererRegistry } from '../expression_renderers'; import { ExpressionAstExpression } from '../ast'; import { ExecutionContract } from '../execution/execution_contract'; @@ -101,7 +101,8 @@ export interface ExpressionsServiceStart { run: = Record>( ast: string | ExpressionAstExpression, input: Input, - context?: ExtraContext + context?: ExtraContext, + options?: ExpressionExecOptions ) => Promise; /** @@ -117,7 +118,8 @@ export interface ExpressionsServiceStart { ast: string | ExpressionAstExpression, // This any is for legacy reasons. input: Input, - context?: ExtraContext + context?: ExtraContext, + options?: ExpressionExecOptions ) => ExecutionContract; /** @@ -212,8 +214,8 @@ export class ExpressionsService implements PersistableState AnyExpressionRenderDefinition) ): void => this.renderers.register(definition); - public readonly run: ExpressionsServiceStart['run'] = (ast, input, context) => - this.executor.run(ast, input, context); + public readonly run: ExpressionsServiceStart['run'] = (ast, input, context, options) => + this.executor.run(ast, input, context, options); public readonly getFunction: ExpressionsServiceStart['getFunction'] = (name) => this.executor.getFunction(name); @@ -244,8 +246,8 @@ export class ExpressionsService implements PersistableState => this.executor.getTypes(); - public readonly execute: ExpressionsServiceStart['execute'] = ((ast, input, context) => { - const execution = this.executor.createExecution(ast, context); + public readonly execute: ExpressionsServiceStart['execute'] = ((ast, input, context, options) => { + const execution = this.executor.createExecution(ast, context, options); execution.start(input); return execution.contract; }) as ExpressionsServiceStart['execute']; diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index c4c40e0812e48..aef4b73f86e34 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -145,11 +145,18 @@ export class ExpressionLoader { this.execution.cancel(); } this.setParams(params); - this.execution = getExpressionsService().execute(expression, params.context, { - search: params.searchContext, - variables: params.variables || {}, - inspectorAdapters: params.inspectorAdapters, - }); + this.execution = getExpressionsService().execute( + expression, + params.context, + { + search: params.searchContext, + variables: params.variables || {}, + inspectorAdapters: params.inspectorAdapters, + }, + { + debug: params.debug, + } + ); const prevDataHandler = this.execution; const data = await prevDataHandler.getData(); @@ -181,6 +188,7 @@ export class ExpressionLoader { if (params.variables && this.params) { this.params.variables = params.variables; } + this.params.debug = Boolean(params.debug); this.params.inspectorAdapters = (params.inspectorAdapters || this.execution?.inspect()) as Adapters; diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index c7b6190b96ed7..3f74fe045dd2c 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -228,7 +228,7 @@ export class Executor = Record AnyExpressionFunctionDefinition)): void; // (undocumented) registerType(typeDefinition: AnyExpressionTypeDefinition | (() => AnyExpressionTypeDefinition)): void; - run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext): Promise; + run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions): Promise; // (undocumented) readonly state: ExecutorContainer; // (undocumented) @@ -609,12 +609,12 @@ export type ExpressionsServiceSetup = Pick = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => ExecutionContract; + execute: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => ExecutionContract; fork: () => ExpressionsService; getFunction: (name: string) => ReturnType; getRenderer: (name: string) => ReturnType; getType: (name: string) => ReturnType; - run: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext) => Promise; + run: = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions) => Promise; } // Warning: (ae-missing-release-tag) "ExpressionsSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -878,6 +878,8 @@ export interface IExpressionLoaderParams { // (undocumented) customRenderers?: []; // (undocumented) + debug?: boolean; + // (undocumented) disableCaching?: boolean; // (undocumented) inspectorAdapters?: Adapters; diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 37a4f4fee6336..054c5ac3dc467 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -45,6 +45,8 @@ export interface IExpressionLoaderParams { searchContext?: ExecutionContextSearch; context?: ExpressionValue; variables?: Record; + // Enables debug tracking on each expression in the AST + debug?: boolean; disableCaching?: boolean; customFunctions?: []; customRenderers?: []; diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index fd6cc8d742cd4..0c9e4af84f6a5 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -210,7 +210,7 @@ export class Executor = Record AnyExpressionFunctionDefinition)): void; // (undocumented) registerType(typeDefinition: AnyExpressionTypeDefinition | (() => AnyExpressionTypeDefinition)): void; - run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext): Promise; + run = Record>(ast: string | ExpressionAstExpression, input: Input, context?: ExtraContext, options?: ExpressionExecOptions): Promise; // (undocumented) readonly state: ExecutorContainer; // (undocumented) diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts index 9b05b9b777b94..a18ad740681bf 100644 --- a/test/functional/apps/dashboard/url_field_formatter.ts +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -46,7 +46,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(currentUrl).to.equal(fieldUrl); }; - describe('Changing field formatter to Url', () => { + // FLAKY: https://github.com/elastic/kibana/issues/79463 + describe.skip('Changing field formatter to Url', () => { before(async function () { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index faf272daba097..e597cc14654bc 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -26,6 +26,7 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const queryBar = getService('queryBar'); + const inspector = getService('inspector'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', @@ -292,5 +293,37 @@ export default function ({ getService, getPageObjects }) { expect(currentUrlWithoutScore).not.to.contain('_score'); }); }); + + describe('refresh interval', function () { + it('should refetch when autofresh is enabled', async () => { + const intervalS = 5; + await PageObjects.timePicker.startAutoRefresh(intervalS); + + // check inspector panel request stats for timestamp + await inspector.open(); + + const getRequestTimestamp = async () => { + const requestStats = await inspector.getTableData(); + const requestTimestamp = requestStats.filter((r) => + r[0].includes('Request timestamp') + )[0][1]; + return requestTimestamp; + }; + + const requestTimestampBefore = await getRequestTimestamp(); + await retry.waitFor('refetch because of refresh interval', async () => { + const requestTimestampAfter = await getRequestTimestamp(); + log.debug( + `Timestamp before: ${requestTimestampBefore}, Timestamp after: ${requestTimestampAfter}` + ); + return requestTimestampBefore !== requestTimestampAfter; + }); + }); + + after(async () => { + await inspector.close(); + await PageObjects.timePicker.pauseAutoRefresh(); + }); + }); }); } diff --git a/test/functional/apps/discover/_field_data.js b/test/functional/apps/discover/_field_data.js index d9cb09432b26f..d45b8f4841cb6 100644 --- a/test/functional/apps/discover/_field_data.js +++ b/test/functional/apps/discover/_field_data.js @@ -27,21 +27,17 @@ export default function ({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/78689 - describe.skip('discover tab', function describeIndexTests() { + describe('discover tab', function describeIndexTests() { this.tags('includeFirefox'); before(async function () { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('discover'); - // delete .kibana index and update configDoc await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*', }); - + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); - await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; diff --git a/test/functional/apps/visualize/_line_chart.js b/test/functional/apps/visualize/_line_chart.js index 24e4ef4a7fe25..8dfc4d352b133 100644 --- a/test/functional/apps/visualize/_line_chart.js +++ b/test/functional/apps/visualize/_line_chart.js @@ -136,15 +136,8 @@ export default function ({ getService, getPageObjects }) { }); it('should request new data when autofresh is enabled', async () => { - // enable autorefresh - const interval = 3; - await PageObjects.timePicker.openQuickSelectTimeMenu(); - await PageObjects.timePicker.inputValue( - 'superDatePickerRefreshIntervalInput', - interval.toString() - ); - await testSubjects.click('superDatePickerToggleRefreshButton'); - await PageObjects.timePicker.closeQuickSelectTimeMenu(); + const intervalS = 3; + await PageObjects.timePicker.startAutoRefresh(intervalS); // check inspector panel request stats for timestamp await inspector.open(); @@ -155,7 +148,7 @@ export default function ({ getService, getPageObjects }) { )[0][1]; // pause to allow time for autorefresh to fire another request - await PageObjects.common.sleep(interval * 1000 * 1.5); + await PageObjects.common.sleep(intervalS * 1000 * 1.5); // get the latest timestamp from request stats const requestStatsAfter = await inspector.getTableData(); diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 237dc8946ae0e..3ac6c83e61f14 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -269,6 +269,17 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo return moment.duration(endMoment.diff(startMoment)).asHours(); } + public async startAutoRefresh(intervalS = 3) { + await this.openQuickSelectTimeMenu(); + await this.inputValue('superDatePickerRefreshIntervalInput', intervalS.toString()); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + log.debug('start auto refresh'); + await testSubjects.click('superDatePickerToggleRefreshButton'); + } + await this.closeQuickSelectTimeMenu(); + } + public async pauseAutoRefresh() { log.debug('pauseAutoRefresh'); const refreshConfig = await this.getRefreshConfig(true); diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index bd276bc91ca3e..ce35b99750419 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -62,6 +62,45 @@ export const getActions = (): FindActionResult[] => [ isPreconfigured: false, referencedByCount: 0, }, + { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: '789', + actionTypeId: '.resilient', + name: 'Connector without mapping', + config: { + apiUrl: 'https://elastic.resilient.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, ]; export const newConfiguration: CasesConfigureRequest = { diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index d7a01ef069867..ee4dcc8e81b95 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -15,7 +15,6 @@ import { import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initCaseConfigureGetActionConnector } from './get_connectors'; -import { getActions } from '../../__mocks__/request_responses'; import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; describe('GET connectors', () => { @@ -24,7 +23,7 @@ describe('GET connectors', () => { routeHandler = await createRoute(initCaseConfigureGetActionConnector, 'get'); }); - it('returns the connectors', async () => { + it('returns case owned connectors', async () => { const req = httpServerMock.createKibanaRequest({ path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, method: 'get', @@ -38,9 +37,67 @@ describe('GET connectors', () => { const res = await routeHandler(context, req, kibanaResponseFactory); expect(res.status).toEqual(200); - expect(res.payload).toEqual( - getActions().filter((action) => action.actionTypeId === '.servicenow') - ); + expect(res.payload).toEqual([ + { + id: '123', + actionTypeId: '.servicenow', + name: 'ServiceNow', + config: { + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + apiUrl: 'https://dev102283.service-now.com', + isCaseOwned: true, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, + ]); }); it('it throws an error when actions client is null', async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 545ccf82c3d78..c3e565a404e97 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -7,15 +7,44 @@ import Boom from 'boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FindActionResult } from '../../../../../../actions/server/types'; import { CASE_CONFIGURE_CONNECTORS_URL, - SUPPORTED_CONNECTORS, SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID, } from '../../../../../common/constants'; +/** + * We need to take into account connectors that have been created within cases and + * they do not have the isCaseOwned field. Checking for the existence of + * the mapping attribute ensures that the connector is indeed a case connector. + * Cases connector should always have a mapping. + */ + +interface CaseAction extends FindActionResult { + config?: { + isCaseOwned?: boolean; + incidentConfiguration?: Record; + }; +} + +const isCaseOwned = (action: CaseAction): boolean => { + if ( + [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( + action.actionTypeId + ) + ) { + if (action.config?.isCaseOwned === true || action.config?.incidentConfiguration?.mapping) { + return true; + } + } + + return false; +}; + /* * Be aware that this api will only return 20 connectors */ @@ -34,18 +63,7 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter( - (action) => - SUPPORTED_CONNECTORS.includes(action.actionTypeId) && - // Need this filtering temporary to display only Case owned ServiceNow connectors - (![SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( - action.actionTypeId - ) || - ([SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( - action.actionTypeId - ) && - action.config?.isCaseOwned === true)) - ); + const results = (await actionsClient.getAll()).filter(isCaseOwned); return response.ok({ body: results }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/groups.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/groups.mock.ts new file mode 100644 index 0000000000000..c4b129d870ea1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/groups.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { contentSources } from './content_sources.mock'; +import { users } from './users.mock'; + +export const groups = [ + { + id: '123', + name: 'group', + createdAt: '2020-10-06', + updatedAt: '2020-10-06', + users, + usersCount: users.length, + color: 'motherofpearl', + contentSources, + canEditGroup: true, + canDeleteGroup: true, + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx new file mode 100644 index 0000000000000..59216126a2372 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { AddGroupModal } from './add_group_modal'; + +import { EuiModal, EuiOverlayMask } from '@elastic/eui'; + +describe('AddGroupModal', () => { + const closeNewGroupModal = jest.fn(); + const saveNewGroup = jest.fn(); + const setNewGroupName = jest.fn(); + + beforeEach(() => { + setMockValues({ + newGroupNameErrors: [], + newGroupName: 'foo', + }); + + setMockActions({ + closeNewGroupModal, + saveNewGroup, + setNewGroupName, + }); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiModal)).toHaveLength(1); + expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); + }); + + it('updates the input value', () => { + const wrapper = shallow(); + + const input = wrapper.find('[data-test-subj="AddGroupInput"]'); + input.simulate('change', { target: { value: 'bar' } }); + + expect(setNewGroupName).toHaveBeenCalledWith('bar'); + }); + + it('submits the form', () => { + const wrapper = shallow(); + + const simulatedEvent = { + form: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + + const form = wrapper.find('form'); + form.simulate('submit', simulatedEvent); + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(saveNewGroup).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.test.tsx new file mode 100644 index 0000000000000..6a781f52c9e95 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ClearFiltersLink } from './clear_filters_link'; + +import { EuiLink } from '@elastic/eui'; + +describe('ClearFiltersLink', () => { + const resetGroupsFilters = jest.fn(); + + beforeEach(() => { + setMockActions({ + resetGroupsFilters, + }); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('handles click', () => { + const wrapper = shallow(); + + const button = wrapper.find(EuiLink); + button.simulate('click'); + + expect(resetGroupsFilters).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.test.tsx new file mode 100644 index 0000000000000..f23a0c8d14042 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiFieldSearch, EuiFilterSelectItem, EuiCard, EuiPopoverTitle } from '@elastic/eui'; + +import { FilterableUsersList } from './filterable_users_list'; + +import { IUser } from '../../../types'; + +const mockSetState = jest.fn(); +const useStateMock: any = (initState: any) => [initState, mockSetState]; + +const addFilteredUser = jest.fn(); +const removeFilteredUser = jest.fn(); + +const props = { + users, + addFilteredUser, + removeFilteredUser, +}; + +describe('FilterableUsersList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + expect(wrapper.find(EuiCard)).toHaveLength(0); + }); + + it('updates the input value and renders zero users card', () => { + jest.spyOn(React, 'useState').mockImplementation(useStateMock); + const _users = [ + users[0], + { + ...users[0], + id: 'asdfa', + email: 'user@example.co', + name: null, + }, + ]; + + const wrapper = shallow(); + + const input = wrapper.find(EuiFieldSearch); + input.simulate('change', { target: { value: 'bar' } }); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + expect(wrapper.find(EuiFilterSelectItem)).toHaveLength(0); + }); + + it('handles adding and removing users', () => { + const _users = [ + users[0], + { + ...users[0], + id: 'asdfa', + }, + ]; + + const wrapper = shallow( + + ); + const firstItem = wrapper.find(EuiFilterSelectItem).first(); + firstItem.simulate('click'); + + expect(removeFilteredUser).toHaveBeenCalled(); + expect(addFilteredUser).not.toHaveBeenCalled(); + + const secondItem = wrapper.find(EuiFilterSelectItem).last(); + secondItem.simulate('click'); + + expect(addFilteredUser).toHaveBeenCalled(); + }); + + it('renders loading when no users', () => { + const wrapper = shallow( + loading} /> + ); + const card = wrapper.find(EuiCard); + + expect((card.prop('description') as any).props.children).toEqual('loading'); + }); + + it('handles hidden users when count is higher than 20', () => { + const _users = [] as IUser[]; + const NUM_TOTAL_USERS = 30; + const NUM_VISIBLE_USERS = 20; + + [...Array(NUM_TOTAL_USERS)].forEach((_, i) => { + _users.push({ + ...users[0], + id: i.toString(), + }); + }); + + const wrapper = shallow(); + + expect(wrapper.find(EuiFilterSelectItem)).toHaveLength(NUM_VISIBLE_USERS); + }); + + it('renders elements wrapped when popover', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiPopoverTitle)).toHaveLength(1); + expect(wrapper.find('.euiFilterSelect__items')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.test.tsx new file mode 100644 index 0000000000000..215a0e3eecdd8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions } from '../../../../__mocks__'; +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { FilterableUsersPopover } from './filterable_users_popover'; +import { FilterableUsersList } from './filterable_users_list'; + +import { EuiFilterGroup, EuiPopover } from '@elastic/eui'; + +const closePopover = jest.fn(); +const addFilteredUser = jest.fn(); +const removeFilteredUser = jest.fn(); + +const props = { + users, + closePopover, + isPopoverOpen: false, + button: <>, +}; + +describe('FilterableUsersPopover', () => { + beforeEach(() => { + setMockActions({ + addFilteredUser, + removeFilteredUser, + }); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFilterGroup)).toHaveLength(1); + expect(wrapper.find(EuiPopover)).toHaveLength(1); + expect(wrapper.find(FilterableUsersList)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx new file mode 100644 index 0000000000000..2826d740d5339 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GroupManagerModal } from './group_manager_modal'; + +import { EuiOverlayMask, EuiModal, EuiEmptyPrompt } from '@elastic/eui'; + +const hideModal = jest.fn(); +const selectAll = jest.fn(); +const saveItems = jest.fn(); + +const props = { + children: <>, + label: 'shared content sources', + allItems: [], + numSelected: 1, + hideModal, + selectAll, + saveItems, +}; + +const mockValues = { + group: groups[0], + contentSources, + managerModalFormErrors: [], +}; + +describe('GroupManagerModal', () => { + beforeEach(() => { + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiModal)).toHaveLength(1); + expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); + }); + + it('renders empty state', () => { + setMockValues({ ...mockValues, contentSources: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('selects all items when clicked', () => { + const wrapper = shallow(); + + const button = wrapper.find('[data-test-subj="SelectAllGroups"]'); + button.simulate('click'); + + expect(selectAll).toHaveBeenCalledWith([]); + }); + + it('deselects all items when clicked', () => { + const wrapper = shallow(); + + const button = wrapper.find('[data-test-subj="SelectAllGroups"]'); + button.simulate('click'); + + expect(selectAll).toHaveBeenCalledWith([{}]); + }); + + it('handles cancel when clicked', () => { + const wrapper = shallow(); + + const button = wrapper.find('[data-test-subj="CloseGroupsModal"]'); + button.simulate('click'); + + expect(hideModal).toHaveBeenCalledWith(groups[0]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index db576808b66e3..c91516edf7b15 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -139,7 +139,7 @@ export const GroupManagerModal: React.FC = ({ - + {i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSelectAllToggle', { @@ -152,7 +152,9 @@ export const GroupManagerModal: React.FC = ({ - {CANCEL_BUTTON_TEXT} + + {CANCEL_BUTTON_TEXT} + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx new file mode 100644 index 0000000000000..acb2fcfbaaa32 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + GroupOverview, + EMPTY_SOURCES_DESCRIPTION, + EMPTY_USERS_DESCRIPTION, +} from './group_overview'; + +import { ContentSection } from '../../../components/shared/content_section'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { SourcesTable } from '../../../components/shared/sources_table'; +import { Loading } from '../../../components/shared/loading'; + +import { EuiFieldText } from '@elastic/eui'; + +const deleteGroup = jest.fn(); +const showSharedSourcesModal = jest.fn(); +const showManageUsersModal = jest.fn(); +const showConfirmDeleteModal = jest.fn(); +const hideConfirmDeleteModal = jest.fn(); +const updateGroupName = jest.fn(); +const onGroupNameInputChange = jest.fn(); + +const mockValues = { + group: groups[0], + groupNameInputValue: '', + dataLoading: false, + confirmDeleteModalVisible: true, +}; + +describe('GroupOverview', () => { + beforeEach(() => { + setMockActions({ + deleteGroup, + showSharedSourcesModal, + showManageUsersModal, + showConfirmDeleteModal, + hideConfirmDeleteModal, + updateGroupName, + onGroupNameInputChange, + }); + setMockValues(mockValues); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ContentSection)).toHaveLength(4); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(SourcesTable)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('updates the input value', () => { + const wrapper = shallow(); + + const input = wrapper.find(EuiFieldText); + input.simulate('change', { target: { value: 'bar' } }); + + expect(onGroupNameInputChange).toHaveBeenCalledWith('bar'); + }); + + it('submits the form', () => { + const wrapper = shallow(); + + const simulatedEvent = { + form: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + + const form = wrapper.find('form'); + form.simulate('submit', simulatedEvent); + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(updateGroupName).toHaveBeenCalled(); + }); + + it('renders empty state messages', () => { + setMockValues({ + ...mockValues, + group: { + ...groups[0], + users: [], + contentSources: [], + }, + }); + + const wrapper = shallow(); + const sourcesSection = wrapper.find('[data-test-subj="GroupContentSourcesSection"]') as any; + const usersSection = wrapper.find('[data-test-subj="GroupUsersSection"]') as any; + + expect(sourcesSection.prop('description')).toEqual(EMPTY_SOURCES_DESCRIPTION); + expect(usersSection.prop('description')).toEqual(EMPTY_USERS_DESCRIPTION); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index 1c7a01a1d9a46..fd97f1c0a03ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -33,7 +33,7 @@ import { GroupUsersTable } from './group_users_table'; import { GroupLogic, MAX_NAME_LENGTH } from '../group_logic'; -const EMPTY_SOURCES_DESCRIPTION = i18n.translate( +export const EMPTY_SOURCES_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptySourcesDescription', { defaultMessage: 'No content sources are shared with this group.', @@ -45,7 +45,7 @@ const GROUP_USERS_DESCRIPTION = i18n.translate( defaultMessage: 'Members will be able to search over the group’s sources.', } ); -const EMPTY_USERS_DESCRIPTION = i18n.translate( +export const EMPTY_USERS_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptyUsersDescription', { defaultMessage: 'There are no users in this group.', @@ -180,6 +180,7 @@ export const GroupOverview: React.FC = () => { title="Group content sources" description={hasContentSources ? GROUP_SOURCES_DESCRIPTION : EMPTY_SOURCES_DESCRIPTION} action={manageSourcesButton} + data-test-subj="GroupContentSourcesSection" > {hasContentSources && sourcesTable} @@ -190,6 +191,7 @@ export const GroupOverview: React.FC = () => { title="Group users" description={hasUsers ? GROUP_USERS_DESCRIPTION : EMPTY_USERS_DESCRIPTION} action={manageUsersButton} + data-test-subj="GroupUsersSection" > {hasUsers && } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.test.tsx new file mode 100644 index 0000000000000..c7eea8ab64d45 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import moment from 'moment'; + +import { GroupRow, NO_USERS_MESSAGE, NO_SOURCES_MESSAGE } from './group_row'; +import { GroupUsers } from './group_users'; + +import { EuiTableRow } from '@elastic/eui'; + +describe('GroupRow', () => { + beforeEach(() => { + setMockValues({ isFederatedAuth: true }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + }); + + it('renders group users', () => { + setMockValues({ isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find(GroupUsers)).toHaveLength(1); + }); + + it('renders fromNow date string when in range', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('small').text()).toEqual('Last updated 7 days ago.'); + }); + + it('renders formatted date string when out of range', () => { + const wrapper = shallow(); + + expect(wrapper.find('small').text()).toEqual('Last updated January 1, 2020.'); + }); + + it('renders empty users message when no users present', () => { + setMockValues({ isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find('.user-group__accounts').text()).toEqual(NO_USERS_MESSAGE); + }); + + it('renders empty sources message when no sources present', () => { + const wrapper = shallow(); + + expect(wrapper.find('.user-group__sources').text()).toEqual(NO_SOURCES_MESSAGE); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx index 9c7276372cf54..9642d48af55f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx @@ -24,13 +24,13 @@ import { GroupSources } from './group_sources'; import { GroupUsers } from './group_users'; const DAYS_CUTOFF = 8; -const NO_SOURCES_MESSAGE = i18n.translate( +export const NO_SOURCES_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.noSourcesMessage', { defaultMessage: 'No shared content sources', } ); -const NO_USERS_MESSAGE = i18n.translate( +export const NO_USERS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.noUsersMessage', { defaultMessage: 'No users', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.test.tsx new file mode 100644 index 0000000000000..9493e52e08b81 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GroupRowSourcesDropdown } from './group_row_sources_dropdown'; +import { SourceOptionItem } from './source_option_item'; + +import { EuiFilterGroup } from '@elastic/eui'; + +const onButtonClick = jest.fn(); +const closePopover = jest.fn(); + +const props = { + isPopoverOpen: true, + numOptions: 1, + groupSources: contentSources, + onButtonClick, + closePopover, +}; + +describe('GroupRowSourcesDropdown', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SourceOptionItem)).toHaveLength(2); + expect(wrapper.find(EuiFilterGroup)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.test.tsx new file mode 100644 index 0000000000000..039a2620f1fd4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow, mount } from 'enzyme'; + +import { EuiLoadingContent, EuiButtonEmpty } from '@elastic/eui'; + +import { GroupRowUsersDropdown } from './group_row_users_dropdown'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +const fetchGroupUsers = jest.fn(); +const onButtonClick = jest.fn(); +const closePopover = jest.fn(); + +const props = { + isPopoverOpen: true, + numOptions: 1, + groupId: '123', + onButtonClick, + closePopover, +}; +describe('GroupRowUsersDropdown', () => { + beforeEach(() => { + setMockActions({ fetchGroupUsers }); + setMockValues({ + allGroupUsers: users, + allGroupUsersLoading: false, + }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(FilterableUsersPopover)).toHaveLength(1); + }); + + it('handles toggle click', () => { + const wrapper = mount(); + + const button = wrapper.find(EuiButtonEmpty); + button.simulate('click'); + + expect(fetchGroupUsers).toHaveBeenCalledWith('123'); + expect(onButtonClick).toHaveBeenCalled(); + }); + + it('handles loading state', () => { + setMockValues({ + allGroupUsers: users, + allGroupUsersLoading: true, + }); + const wrapper = shallow(); + const popover = wrapper.find(FilterableUsersPopover); + + expect(popover.prop('allGroupUsersLoading')).toEqual(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.test.tsx new file mode 100644 index 0000000000000..81639327f4ba0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Loading } from '../../../components/shared/loading'; + +import { GroupSourcePrioritization } from './group_source_prioritization'; + +import { EuiTable, EuiEmptyPrompt, EuiRange } from '@elastic/eui'; + +const updatePriority = jest.fn(); +const saveGroupSourcePrioritization = jest.fn(); +const showSharedSourcesModal = jest.fn(); + +const mockValues = { + group: groups[0], + activeSourcePriorities: [ + { + [groups[0].id]: 1, + }, + ], + dataLoading: false, + groupPrioritiesUnchanged: true, +}; + +describe('GroupSourcePrioritization', () => { + beforeEach(() => { + setMockActions({ + updatePriority, + saveGroupSourcePrioritization, + showSharedSourcesModal, + }); + + setMockValues(mockValues); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTable)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders empty state', () => { + setMockValues({ + ...mockValues, + group: { + ...groups[0], + contentSources: [], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('handles slider change', () => { + const wrapper = shallow(); + + const slider = wrapper.find(EuiRange).first(); + slider.simulate('change', { target: { value: 2 } }); + + expect(updatePriority).toHaveBeenCalledWith('123', 2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.test.tsx new file mode 100644 index 0000000000000..38c56aefebafd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GroupSources } from './group_sources'; +import { GroupRowSourcesDropdown } from './group_row_sources_dropdown'; + +import { SourceIcon } from '../../../components/shared/source_icon'; + +import { IContentSourceDetails } from '../../../types'; + +describe('GroupSources', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SourceIcon)).toHaveLength(2); + }); + + it('handles hidden sources when count is higer than 10', () => { + const sources = [] as IContentSourceDetails[]; + const NUM_TOTAL_SOURCES = 10; + + [...Array(NUM_TOTAL_SOURCES)].forEach((_, i) => { + sources.push({ + ...contentSources[0], + id: i.toString(), + }); + }); + + const wrapper = shallow(); + + // These were needed for 100% test coverage. + wrapper.find(GroupRowSourcesDropdown).invoke('onButtonClick')(); + wrapper.find(GroupRowSourcesDropdown).invoke('closePopover')(); + + expect(wrapper.find(GroupRowSourcesDropdown)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.test.tsx new file mode 100644 index 0000000000000..7ddecc21c22c4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { GroupSubNav } from './group_sub_nav'; + +import { SideNavLink } from '../../../../shared/layout'; + +describe('GroupSubNav', () => { + it('renders empty when no group id present', () => { + setMockValues({ group: {} }); + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(0); + }); + + it('renders nav items', () => { + setMockValues({ group: { id: '1' } }); + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.test.tsx new file mode 100644 index 0000000000000..6a635eacf2585 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { IUser } from '../../../types'; + +import { GroupUsers } from './group_users'; +import { GroupRowUsersDropdown } from './group_row_users_dropdown'; + +import { UserIcon } from '../../../components/shared/user_icon'; + +const props = { + groupUsers: users, + usersCount: 1, + groupId: '123', +}; + +describe('GroupUsers', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserIcon)).toHaveLength(1); + }); + + it('handles hidden users when count is higher than 20', () => { + const _users = [] as IUser[]; + const NUM_TOTAL_USERS = 20; + + [...Array(NUM_TOTAL_USERS)].forEach((_, i) => { + _users.push({ + ...users[0], + id: i.toString(), + }); + }); + + const wrapper = shallow(); + + // These were needed for 100% test coverage. + wrapper.find(GroupRowUsersDropdown).invoke('onButtonClick')(); + wrapper.find(GroupRowUsersDropdown).invoke('closePopover')(); + + expect(wrapper.find(GroupRowUsersDropdown)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.test.tsx new file mode 100644 index 0000000000000..8747a838689c4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { IUser } from '../../../types'; + +import { GroupUsersTable } from './group_users_table'; +import { TableHeader } from '../../../../shared/table_header'; + +import { EuiTable, EuiTablePagination } from '@elastic/eui'; + +const group = groups[0]; + +describe('GroupUsersTable', () => { + it('renders', () => { + setMockValues({ isFederatedAuth: true, group }); + const wrapper = shallow(); + + expect(wrapper.find(EuiTable)).toHaveLength(1); + expect(wrapper.find(TableHeader).prop('headerItems')).toHaveLength(1); + }); + + it('adds header item for non-federated auth', () => { + setMockValues({ isFederatedAuth: false, group }); + const wrapper = shallow(); + + expect(wrapper.find(TableHeader).prop('headerItems')).toHaveLength(2); + }); + + it('renders pagination', () => { + const users = [] as IUser[]; + const NUM_TOTAL_USERS = 20; + + [...Array(NUM_TOTAL_USERS)].forEach((_, i) => { + users.push({ + ...group.users[0], + id: i.toString(), + }); + }); + + setMockValues({ isFederatedAuth: true, group: { users } }); + const wrapper = shallow(); + const pagination = wrapper.find(EuiTablePagination); + + // This was needed for 100% test coverage. The tests pass and 100% coverage + // is achieved with this line, but TypeScript complains anyway, so ignoring line. + // @ts-ignore + pagination.invoke('onChangePage')(1); + + expect(pagination).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.test.tsx new file mode 100644 index 0000000000000..38d035cbca908 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import { DEFAULT_META } from '../../../../shared/constants'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; + +import { GroupsTable } from './groups_table'; +import { GroupRow } from './group_row'; +import { ClearFiltersLink } from './clear_filters_link'; + +import { EuiTable, EuiTableHeaderCell } from '@elastic/eui'; + +const setActivePage = jest.fn(); + +const mockValues = { + groupsMeta: DEFAULT_META, + groups, + hasFiltersSet: false, + isFederatedAuth: true, +}; + +describe('GroupsTable', () => { + beforeEach(() => { + setMockActions({ setActivePage }); + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTable)).toHaveLength(1); + expect(wrapper.find(GroupRow)).toHaveLength(1); + }); + + it('renders extra header for non-federated auth', () => { + setMockValues({ ...mockValues, isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find(EuiTableHeaderCell)).toHaveLength(4); + }); + + it('handles pagination', () => { + setMockValues({ + ...mockValues, + groupsMeta: { + page: { + current: 1, + size: 10, + total_pages: 3, + total_results: 30, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(TablePaginationBar).first().invoke('onChangePage')(1); + + expect(setActivePage).toHaveBeenCalledWith(2); + expect(wrapper.find(TablePaginationBar)).toHaveLength(2); + }); + + it('renders clear filters link when filters set', () => { + setMockValues({ ...mockValues, hasFiltersSet: true }); + const wrapper = shallow(); + + expect(wrapper.find(ClearFiltersLink)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.test.tsx new file mode 100644 index 0000000000000..34f748e8a7169 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ManageUsersModal } from './manage_users_modal'; +import { FilterableUsersList } from './filterable_users_list'; +import { GroupManagerModal } from './group_manager_modal'; + +const addGroupUser = jest.fn(); +const removeGroupUser = jest.fn(); +const selectAllUsers = jest.fn(); +const hideManageUsersModal = jest.fn(); +const saveGroupUsers = jest.fn(); + +describe('ManageUsersModal', () => { + it('renders', () => { + setMockActions({ + addGroupUser, + removeGroupUser, + selectAllUsers, + hideManageUsersModal, + saveGroupUsers, + }); + + setMockValues({ + users, + selectedGroupUsers: [], + }); + + const wrapper = shallow(); + + expect(wrapper.find(FilterableUsersList)).toHaveLength(1); + expect(wrapper.find(GroupManagerModal)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.test.tsx new file mode 100644 index 0000000000000..8c5ead2509d9e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { groups } from '../../../__mocks__/groups.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SharedSourcesModal } from './shared_sources_modal'; +import { GroupManagerModal } from './group_manager_modal'; +import { SourcesList } from './sources_list'; + +const group = groups[0]; + +const addGroupSource = jest.fn(); +const selectAllSources = jest.fn(); +const hideSharedSourcesModal = jest.fn(); +const removeGroupSource = jest.fn(); +const saveGroupSources = jest.fn(); + +describe('SharedSourcesModal', () => { + it('renders', () => { + setMockActions({ + addGroupSource, + selectAllSources, + hideSharedSourcesModal, + removeGroupSource, + saveGroupSources, + }); + + setMockValues({ + group, + selectedGroupSources: [], + contentSources: group.contentSources, + }); + + const wrapper = shallow(); + + expect(wrapper.find(SourcesList)).toHaveLength(1); + expect(wrapper.find(GroupManagerModal)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.test.tsx new file mode 100644 index 0000000000000..8a3901f5462df --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SourceOptionItem } from './source_option_item'; + +import { TruncatedContent } from '../../../../shared/truncate'; + +import { SourceIcon } from '../../../components/shared/source_icon'; + +describe('SourceOptionItem', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(TruncatedContent)).toHaveLength(1); + expect(wrapper.find(SourceIcon)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.test.tsx new file mode 100644 index 0000000000000..05754f3846bb0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SourcesList } from './sources_list'; + +import { EuiFilterSelectItem } from '@elastic/eui'; + +const addFilteredSource = jest.fn(); +const removeFilteredSource = jest.fn(); + +const props = { + contentSources, + filteredSources: [], + addFilteredSource, + removeFilteredSource, +}; + +describe('SourcesList', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFilterSelectItem)).toHaveLength(2); + }); + + it('handles adding item click when item unchecked', () => { + const wrapper = shallow(); + wrapper.find(EuiFilterSelectItem).first().simulate('click'); + + expect(addFilteredSource).toHaveBeenCalled(); + }); + + it('handles removing item click when item checked', () => { + const wrapper = shallow(); + wrapper.find(EuiFilterSelectItem).first().simulate('click'); + + expect(removeFilteredSource).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.test.tsx new file mode 100644 index 0000000000000..e75feb4254929 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TableFilterSourcesDropdown } from './table_filter_sources_dropdown'; + +import { SourcesList } from './sources_list'; + +const addFilteredSource = jest.fn(); +const removeFilteredSource = jest.fn(); +const toggleFilterSourcesDropdown = jest.fn(); +const closeFilterSourcesDropdown = jest.fn(); + +describe('TableFilterSourcesDropdown', () => { + it('renders', () => { + setMockActions({ + addFilteredSource, + removeFilteredSource, + toggleFilterSourcesDropdown, + closeFilterSourcesDropdown, + }); + + setMockValues({ contentSources, filterSourcesDropdownOpen: false, filteredSources: [] }); + + const wrapper = shallow(); + + expect(wrapper.find(SourcesList)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.test.tsx new file mode 100644 index 0000000000000..9d461e06a77ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TableFilterUsersDropdown } from './table_filter_users_dropdown'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +const closeFilterUsersDropdown = jest.fn(); +const toggleFilterUsersDropdown = jest.fn(); + +describe('TableFilterUsersDropdown', () => { + it('renders', () => { + setMockActions({ closeFilterUsersDropdown, toggleFilterUsersDropdown }); + setMockValues({ users, filteredUsers: [], filterUsersDropdownOpen: false }); + + const wrapper = shallow(); + + expect(wrapper.find(FilterableUsersPopover)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.test.tsx new file mode 100644 index 0000000000000..80662bc0974a1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { setMockActions, setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TableFilters } from './table_filters'; + +import { EuiFieldSearch } from '@elastic/eui'; +import { TableFilterSourcesDropdown } from './table_filter_sources_dropdown'; +import { TableFilterUsersDropdown } from './table_filter_users_dropdown'; + +const setFilterValue = jest.fn(); + +describe('TableFilters', () => { + beforeEach(() => { + setMockValues({ filterValue: '', isFederatedAuth: true }); + setMockActions({ setFilterValue }); + }); + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + expect(wrapper.find(TableFilterSourcesDropdown)).toHaveLength(1); + expect(wrapper.find(TableFilterUsersDropdown)).toHaveLength(0); + }); + + it('renders for non-federated Auth', () => { + setMockValues({ filterValue: '', isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find(TableFilterUsersDropdown)).toHaveLength(1); + }); + + it('handles search input value change', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldSearch); + input.simulate('change', { target: { value: 'bar' } }); + + expect(setFilterValue).toHaveBeenCalledWith('bar'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.test.tsx new file mode 100644 index 0000000000000..72611f254d01c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/kea.mock'; + +import { users } from '../../../__mocks__/users.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { UserOptionItem } from './user_option_item'; +import { UserIcon } from '../../../components/shared/user_icon'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +const user = users[0]; + +describe('UserOptionItem', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserIcon)).toHaveLength(1); + expect(wrapper.find(EuiFlexGroup)).toHaveLength(1); + expect(wrapper.find(EuiFlexItem)).toHaveLength(2); + }); + + it('falls back to email when name not present', () => { + const wrapper = shallow(); + const nameItem = wrapper.find(EuiFlexItem).last(); + + expect(nameItem.prop('children')).toEqual(user.email); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts index 40037d0c1e777..e87f4aa17c0ef 100644 --- a/x-pack/plugins/index_lifecycle_management/server/plugin.ts +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -14,6 +14,7 @@ import { PluginInitializerContext, LegacyAPICaller, } from 'src/core/server'; +import { handleEsError } from './shared_imports'; import { Index as IndexWithoutIlm } from '../../index_management/common/types'; import { PLUGIN } from '../common/constants'; @@ -99,6 +100,9 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { @@ -47,15 +51,8 @@ export function registerAddPolicyRoute({ router, license }: RouteDependencies) { alias ); return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts index a83a3fa1378c8..15c3e7b866c79 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_remove_route.ts @@ -26,7 +26,11 @@ const bodySchema = schema.object({ indexNames: schema.arrayOf(schema.string()), }); -export function registerRemoveRoute({ router, license }: RouteDependencies) { +export function registerRemoveRoute({ + router, + license, + lib: { handleEsError }, +}: RouteDependencies) { router.post( { path: addBasePath('/index/remove'), validate: { body: bodySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -36,15 +40,8 @@ export function registerRemoveRoute({ router, license }: RouteDependencies) { try { await removeLifecycle(context.core.elasticsearch.client.asCurrentUser, indexNames); return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts index cdcf5ed4b7ac4..28bced0fb5a8f 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/index/register_retry_route.ts @@ -27,7 +27,7 @@ const bodySchema = schema.object({ indexNames: schema.arrayOf(schema.string()), }); -export function registerRetryRoute({ router, license }: RouteDependencies) { +export function registerRetryRoute({ router, license, lib: { handleEsError } }: RouteDependencies) { router.post( { path: addBasePath('/index/retry'), validate: { body: bodySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -37,15 +37,8 @@ export function registerRetryRoute({ router, license }: RouteDependencies) { try { await retryLifecycle(context.core.elasticsearch.client.asCurrentUser, indexNames); return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts index 57034af324ed5..41b93ba59e3f5 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_details_route.ts @@ -29,7 +29,11 @@ const paramsSchema = schema.object({ nodeAttrs: schema.string(), }); -export function registerDetailsRoute({ router, license }: RouteDependencies) { +export function registerDetailsRoute({ + router, + license, + lib: { handleEsError }, +}: RouteDependencies) { router.get( { path: addBasePath('/nodes/{nodeAttrs}/details'), validate: { params: paramsSchema } }, license.guardApiRoute(async (context, request, response) => { @@ -40,15 +44,8 @@ export function registerDetailsRoute({ router, license }: RouteDependencies) { const statsResponse = await context.core.elasticsearch.client.asCurrentUser.nodes.stats(); const okResponse = { body: findMatchingNodes(statsResponse.body, nodeAttrs) }; return response.ok(okResponse); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts index bb1679e695e14..53955d93c1e09 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts @@ -62,7 +62,12 @@ export function convertSettingsIntoLists( ); } -export function registerListRoute({ router, config, license }: RouteDependencies) { +export function registerListRoute({ + router, + config, + license, + lib: { handleEsError }, +}: RouteDependencies) { const { filteredNodeAttributes } = config; const NODE_ATTRS_KEYS_TO_IGNORE: string[] = [ @@ -95,15 +100,8 @@ export function registerListRoute({ router, config, license }: RouteDependencies disallowedNodeAttributes ); return response.ok({ body }); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index 359b275622f0c..d8e40e3b30410 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -134,7 +134,11 @@ const bodySchema = schema.object({ }), }); -export function registerCreateRoute({ router, license }: RouteDependencies) { +export function registerCreateRoute({ + router, + license, + lib: { handleEsError }, +}: RouteDependencies) { router.post( { path: addBasePath('/policies'), validate: { body: bodySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -144,15 +148,8 @@ export function registerCreateRoute({ router, license }: RouteDependencies) { try { await createPolicy(context.core.elasticsearch.client.asCurrentUser, name, phases); return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts index cb394c12c46fa..b0363cb7c3bc6 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_delete_route.ts @@ -23,7 +23,11 @@ const paramsSchema = schema.object({ policyNames: schema.string(), }); -export function registerDeleteRoute({ router, license }: RouteDependencies) { +export function registerDeleteRoute({ + router, + license, + lib: { handleEsError }, +}: RouteDependencies) { router.delete( { path: addBasePath('/policies/{policyNames}'), validate: { params: paramsSchema } }, license.guardApiRoute(async (context, request, response) => { @@ -33,15 +37,8 @@ export function registerDeleteRoute({ router, license }: RouteDependencies) { try { await deletePolicies(context.core.elasticsearch.client.asCurrentUser, policyNames); return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts index 8cbea25666378..fc5f369e588f1 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_fetch_route.ts @@ -57,7 +57,7 @@ const querySchema = schema.object({ withIndices: schema.boolean({ defaultValue: false }), }); -export function registerFetchRoute({ router, license }: RouteDependencies) { +export function registerFetchRoute({ router, license, lib: { handleEsError } }: RouteDependencies) { router.get( { path: addBasePath('/policies'), validate: { query: querySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -75,15 +75,8 @@ export function registerFetchRoute({ router, license }: RouteDependencies) { await addLinkedIndices(asCurrentUser, policiesMap); } return response.ok({ body: formatPolicies(policiesMap) }); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts index 869be3d557040..00afc31c03bad 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts @@ -14,7 +14,7 @@ async function fetchSnapshotPolicies(client: ElasticsearchClient): Promise return response.body; } -export function registerFetchRoute({ router, license }: RouteDependencies) { +export function registerFetchRoute({ router, license, lib: { handleEsError } }: RouteDependencies) { router.get( { path: addBasePath('/snapshot_policies'), validate: false }, license.guardApiRoute(async (context, request, response) => { @@ -23,15 +23,8 @@ export function registerFetchRoute({ router, license }: RouteDependencies) { context.core.elasticsearch.client.asCurrentUser ); return response.ok({ body: Object.keys(policiesByName) }); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts index 7e7f3f1f725f8..667491ef4af65 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts @@ -92,7 +92,11 @@ const querySchema = schema.object({ legacy: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), }); -export function registerAddPolicyRoute({ router, license }: RouteDependencies) { +export function registerAddPolicyRoute({ + router, + license, + lib: { handleEsError }, +}: RouteDependencies) { router.post( { path: addBasePath('/template'), validate: { body: bodySchema, query: querySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -118,15 +122,8 @@ export function registerAddPolicyRoute({ router, license }: RouteDependencies) { }); } return response.ok(); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts index fbd102d3be1eb..35860d2177329 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts @@ -80,7 +80,7 @@ const querySchema = schema.object({ legacy: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), }); -export function registerFetchRoute({ router, license }: RouteDependencies) { +export function registerFetchRoute({ router, license, lib: { handleEsError } }: RouteDependencies) { router.get( { path: addBasePath('/templates'), validate: { query: querySchema } }, license.guardApiRoute(async (context, request, response) => { @@ -92,15 +92,8 @@ export function registerFetchRoute({ router, license }: RouteDependencies) { ); const okResponse = { body: filterTemplates(templates, isLegacy) }; return response.ok(okResponse); - } catch (e) { - if (e.name === 'ResponseError') { - return response.customError({ - statusCode: e.statusCode, - body: { message: e.body.error?.reason }, - }); - } - // Case: default - return response.internalError({ body: e }); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts new file mode 100644 index 0000000000000..068cddcee4c86 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/index_lifecycle_management/server/types.ts b/x-pack/plugins/index_lifecycle_management/server/types.ts index e34dc8e4b1a52..8de7a01f1febc 100644 --- a/x-pack/plugins/index_lifecycle_management/server/types.ts +++ b/x-pack/plugins/index_lifecycle_management/server/types.ts @@ -11,6 +11,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; import { License } from './services'; import { IndexLifecycleManagementConfig } from './config'; +import { handleEsError } from './shared_imports'; export interface Dependencies { licensing: LicensingPluginSetup; @@ -22,4 +23,7 @@ export interface RouteDependencies { router: IRouter; config: IndexLifecycleManagementConfig; license: License; + lib: { + handleEsError: typeof handleEsError; + }; } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts index 02d5dfc64d07d..5b5583a121e5d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts @@ -7,6 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { CallESAsCurrentUser, ElasticsearchAssetType, EsAssetReference } from '../../../../types'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common/constants'; +import { appContextService } from '../../../app_context'; export const stopTransforms = async (transformIds: string[], callCluster: CallESAsCurrentUser) => { for (const transformId of transformIds) { @@ -28,7 +29,7 @@ export const deleteTransforms = async ( // get the index the transform const transformResponse: { count: number; - transforms: Array<{ + transforms?: Array<{ dest: { index: string; }; @@ -36,6 +37,7 @@ export const deleteTransforms = async ( } = await callCluster('transport.request', { method: 'GET', path: `/_transform/${transformId}`, + ignore: [404], }); await stopTransforms([transformId], callCluster); @@ -46,13 +48,17 @@ export const deleteTransforms = async ( ignore: [404], }); - // expect this to be 1 - for (const transform of transformResponse.transforms) { - await callCluster('transport.request', { - method: 'DELETE', - path: `/${transform?.dest?.index}`, - ignore: [404], - }); + if (transformResponse?.transforms) { + // expect this to be 1 + for (const transform of transformResponse.transforms) { + await callCluster('transport.request', { + method: 'DELETE', + path: `/${transform?.dest?.index}`, + ignore: [404], + }); + } + } else { + appContextService.getLogger().warn(`cannot find transform for ${transformId}`); } }) ); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts index 768c6af1d8915..2bf0ad12856f8 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts @@ -160,6 +160,7 @@ describe('test transform install', () => { { method: 'GET', path: '/_transform/endpoint.metadata_current-default-0.15.0-dev.0', + ignore: [404], }, ], [ @@ -446,6 +447,7 @@ describe('test transform install', () => { { method: 'GET', path: '/_transform/endpoint.metadata-current-default-0.15.0-dev.0', + ignore: [404], }, ], [ 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 8b0c9011f2c27..ed0591219b559 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 @@ -264,7 +264,11 @@ export function DimensionEditor(props: DimensionEditorProps) { 3 ? 'lnsIndexPatternDimensionEditor__columns' : ''} gutterSize="none" - listItems={sideNavItems} + listItems={ + // add a padding item containing a non breakable space if the number of operations is not even + // otherwise the column layout will break within an element + sideNavItems.length % 2 === 1 ? [...sideNavItems, { label: '\u00a0' }] : sideNavItems + } maxWidth={false} /> 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 d15825718682c..1bf5039ef05fa 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 @@ -993,9 +993,11 @@ describe('IndexPatternDimensionEditorPanel', () => { 'Average', 'Count', 'Maximum', + 'Median', 'Minimum', 'Sum', 'Unique count', + '\u00a0', ]); }); 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 38aec866ca5cb..735015492bd5a 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 @@ -18,6 +18,8 @@ import { SumIndexPatternColumn, maxOperation, MaxIndexPatternColumn, + medianOperation, + MedianIndexPatternColumn, } from './metrics'; import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram'; import { countOperation, CountIndexPatternColumn } from './count'; @@ -43,6 +45,7 @@ export type IndexPatternColumn = | AvgIndexPatternColumn | CardinalityIndexPatternColumn | SumIndexPatternColumn + | MedianIndexPatternColumn | CountIndexPatternColumn; export type FieldBasedIndexPatternColumn = Extract; @@ -59,6 +62,7 @@ const internalOperationDefinitions = [ averageOperation, cardinalityOperation, sumOperation, + medianOperation, countOperation, rangeOperation, ]; 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 c02f7bcb7d2cd..1d3ecc165ce74 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 @@ -87,6 +87,7 @@ export type SumIndexPatternColumn = MetricColumn<'sum'>; export type AvgIndexPatternColumn = MetricColumn<'avg'>; export type MinIndexPatternColumn = MetricColumn<'min'>; export type MaxIndexPatternColumn = MetricColumn<'max'>; +export type MedianIndexPatternColumn = MetricColumn<'median'>; export const minOperation = buildMetricOperation({ type: 'min', @@ -137,3 +138,15 @@ export const sumOperation = buildMetricOperation({ values: { name }, }), }); + +export const medianOperation = buildMetricOperation({ + type: 'median', + displayName: i18n.translate('xpack.lens.indexPattern.median', { + defaultMessage: 'Median', + }), + ofName: (name) => + i18n.translate('xpack.lens.indexPattern.medianOf', { + defaultMessage: 'Median of {name}', + values: { name }, + }), +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index c1bd4b84099b7..6808bc724f26b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -315,12 +315,12 @@ describe('getOperationTypesForField', () => { }, Object { "field": "bytes", - "operationType": "min", + "operationType": "max", "type": "field", }, Object { "field": "bytes", - "operationType": "max", + "operationType": "min", "type": "field", }, Object { @@ -338,6 +338,11 @@ describe('getOperationTypesForField', () => { "operationType": "cardinality", "type": "field", }, + Object { + "field": "bytes", + "operationType": "median", + "type": "field", + }, ], }, ] diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d0c24b0239b62..ba96d5ee9cdb2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18015,7 +18015,6 @@ "xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage": "アクションタイプを読み込めません", "xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage": "アラートを読み込めません", "xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage": "アラートタイプを読み込めません", - "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.addBccButton": "{titleBcc}", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.addCcButton": "Cc を追加", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel": "送信元", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel": "ホスト", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index db73cd8043e7e..7e5882279b406 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18026,7 +18026,6 @@ "xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage": "无法加载操作类型", "xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage": "无法加载告警", "xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage": "无法加载告警类型", - "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.addBccButton": "{titleBcc}", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.addCcButton": "添加抄送收件人", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel": "发送者", "xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel": "主机", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx index 39c59a10fbc81..a91cf3e7552bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -52,7 +52,7 @@ export const EmailParamsFields = ({ {!addCC ? ( setAddCC(true)}> @@ -60,9 +60,8 @@ export const EmailParamsFields = ({ {!addBCC ? ( setAddBCC(true)}> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 34569dcc75240..3e229c6a2333d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -366,7 +366,7 @@ describe('action_form', () => { `); }); - it('does not render "Add new" button for preconfigured only action type', async () => { + it('does not render "Add connector" button for preconfigured only action type', async () => { await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 94571c4eb1e5b..61cf3f2d37925 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -279,7 +279,7 @@ export const ActionForm = ({ }} > diff --git a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts b/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts index 5ba1aac4c8f92..5195d28d84830 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts @@ -13,6 +13,8 @@ import { getServiceNowConnector, getJiraConnector, getResilientConnector, + getConnectorWithoutCaseOwned, + getConnectorWithoutMapping, } from '../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -36,13 +38,13 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return the correct connectors', async () => { - const { body: connectorOne } = await supertest + const { body: snConnector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') .send(getServiceNowConnector()) .expect(200); - const { body: connectorTwo } = await supertest + const { body: emailConnector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') .send({ @@ -59,22 +61,36 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - const { body: connectorThree } = await supertest + const { body: jiraConnector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') .send(getJiraConnector()) .expect(200); - const { body: connectorFour } = await supertest + const { body: resilientConnector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') .send(getResilientConnector()) .expect(200); - actionsRemover.add('default', connectorOne.id, 'action', 'actions'); - actionsRemover.add('default', connectorTwo.id, 'action', 'actions'); - actionsRemover.add('default', connectorThree.id, 'action', 'actions'); - actionsRemover.add('default', connectorFour.id, 'action', 'actions'); + const { body: connectorWithoutCaseOwned } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getConnectorWithoutCaseOwned()) + .expect(200); + + const { body: connectorNoMapping } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getConnectorWithoutMapping()) + .expect(200); + + actionsRemover.add('default', snConnector.id, 'action', 'actions'); + actionsRemover.add('default', emailConnector.id, 'action', 'actions'); + actionsRemover.add('default', jiraConnector.id, 'action', 'actions'); + actionsRemover.add('default', resilientConnector.id, 'action', 'actions'); + actionsRemover.add('default', connectorWithoutCaseOwned.id, 'action', 'actions'); + actionsRemover.add('default', connectorNoMapping.id, 'action', 'actions'); const { body: connectors } = await supertest .get(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`) @@ -82,16 +98,131 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); - expect(connectors.length).to.equal(3); - expect( - connectors.some((c: { actionTypeId: string }) => c.actionTypeId === '.servicenow') - ).to.equal(true); - expect(connectors.some((c: { actionTypeId: string }) => c.actionTypeId === '.jira')).to.equal( - true - ); - expect( - connectors.some((c: { actionTypeId: string }) => c.actionTypeId === '.resilient') - ).to.equal(true); + expect(connectors).to.eql([ + { + id: connectorWithoutCaseOwned.id, + actionTypeId: '.resilient', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'http://some.non.existent.com', + orgId: 'pkey', + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + isCaseOwned: null, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: jiraConnector.id, + actionTypeId: '.jira', + name: 'Jira Connector', + config: { + apiUrl: 'http://some.non.existent.com', + projectKey: 'pkey', + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'summary', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + isCaseOwned: true, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: resilientConnector.id, + actionTypeId: '.resilient', + name: 'Resilient Connector', + config: { + apiUrl: 'http://some.non.existent.com', + orgId: 'pkey', + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + isCaseOwned: true, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + { + id: snConnector.id, + actionTypeId: '.servicenow', + name: 'ServiceNow Connector', + config: { + apiUrl: 'http://some.non.existent.com', + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'append', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + isCaseOwned: true, + }, + isPreconfigured: false, + referencedByCount: 0, + }, + ]); }); }); }; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 8d28f647ce43b..262e14fac6d8c 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -116,7 +116,7 @@ export const getResilientConnector = () => ({ mapping: [ { source: 'title', - target: 'summary', + target: 'name', actionType: 'overwrite', }, { @@ -135,6 +135,51 @@ export const getResilientConnector = () => ({ }, }); +export const getConnectorWithoutCaseOwned = () => ({ + name: 'Connector without isCaseOwned', + actionTypeId: '.resilient', + secrets: { + apiKeyId: 'id', + apiKeySecret: 'secret', + }, + config: { + apiUrl: 'http://some.non.existent.com', + orgId: 'pkey', + incidentConfiguration: { + mapping: [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + }, +}); + +export const getConnectorWithoutMapping = () => ({ + name: 'Connector without mapping', + actionTypeId: '.resilient', + secrets: { + apiKeyId: 'id', + apiKeySecret: 'secret', + }, + config: { + apiUrl: 'http://some.non.existent.com', + orgId: 'pkey', + }, +}); + export const removeServerGeneratedPropertiesFromConfigure = ( config: Partial ): Partial => {