diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 517aefb36e8d65..4058fcaadee5da 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -13,8 +13,9 @@ jobs: with: issue-mappings: | [ - { "label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173897 }, - { "label": "Feature:Lens", "projectName": "Lens", "columnId": 6219362 }, - { "label": "Team:Canvas", "projectName": "canvas", "columnId": 6187580 } ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} + +# { "label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173897 }, +# { "label": "Feature:Lens", "projectName": "Lens", "columnId": 6219362 }, +# { "label": "Team:Canvas", "projectName": "canvas", "columnId": 6187580 } diff --git a/docs/development/core/server/kibana-plugin-server.authnothandled.md b/docs/development/core/server/kibana-plugin-server.authnothandled.md new file mode 100644 index 00000000000000..01e465c266319b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authnothandled.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthNotHandled](./kibana-plugin-server.authnothandled.md) + +## AuthNotHandled interface + + +Signature: + +```typescript +export interface AuthNotHandled +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-server.authnothandled.type.md) | AuthResultType.notHandled | | + diff --git a/docs/development/core/server/kibana-plugin-server.authnothandled.type.md b/docs/development/core/server/kibana-plugin-server.authnothandled.type.md new file mode 100644 index 00000000000000..81543de0ec61b5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authnothandled.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthNotHandled](./kibana-plugin-server.authnothandled.md) > [type](./kibana-plugin-server.authnothandled.type.md) + +## AuthNotHandled.type property + +Signature: + +```typescript +type: AuthResultType.notHandled; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authredirected.md b/docs/development/core/server/kibana-plugin-server.authredirected.md new file mode 100644 index 00000000000000..3eb88d6c5a2307 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authredirected.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirected](./kibana-plugin-server.authredirected.md) + +## AuthRedirected interface + + +Signature: + +```typescript +export interface AuthRedirected extends AuthRedirectedParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-server.authredirected.type.md) | AuthResultType.redirected | | + diff --git a/docs/development/core/server/kibana-plugin-server.authredirected.type.md b/docs/development/core/server/kibana-plugin-server.authredirected.type.md new file mode 100644 index 00000000000000..866ed358119e7d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authredirected.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirected](./kibana-plugin-server.authredirected.md) > [type](./kibana-plugin-server.authredirected.type.md) + +## AuthRedirected.type property + +Signature: + +```typescript +type: AuthResultType.redirected; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authredirectedparams.headers.md b/docs/development/core/server/kibana-plugin-server.authredirectedparams.headers.md new file mode 100644 index 00000000000000..c1cf8218e75094 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authredirectedparams.headers.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) > [headers](./kibana-plugin-server.authredirectedparams.headers.md) + +## AuthRedirectedParams.headers property + +Headers to attach for auth redirect. Must include "location" header + +Signature: + +```typescript +headers: { + location: string; + } & ResponseHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authredirectedparams.md b/docs/development/core/server/kibana-plugin-server.authredirectedparams.md new file mode 100644 index 00000000000000..3658f88fb64950 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authredirectedparams.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) + +## AuthRedirectedParams interface + +Result of auth redirection. + +Signature: + +```typescript +export interface AuthRedirectedParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-server.authredirectedparams.headers.md) | {
location: string;
} & ResponseHeaders | Headers to attach for auth redirect. Must include "location" header | + diff --git a/docs/development/core/server/kibana-plugin-server.authresult.md b/docs/development/core/server/kibana-plugin-server.authresult.md index 8739c4899bd02a..f540173f34c7ca 100644 --- a/docs/development/core/server/kibana-plugin-server.authresult.md +++ b/docs/development/core/server/kibana-plugin-server.authresult.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AuthResult = Authenticated; +export declare type AuthResult = Authenticated | AuthNotHandled | AuthRedirected; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultparams.md b/docs/development/core/server/kibana-plugin-server.authresultparams.md index 55b247f21f5a9e..7a725cb340f5bc 100644 --- a/docs/development/core/server/kibana-plugin-server.authresultparams.md +++ b/docs/development/core/server/kibana-plugin-server.authresultparams.md @@ -4,7 +4,7 @@ ## AuthResultParams interface -Result of an incoming request authentication. +Result of successful authentication. Signature: diff --git a/docs/development/core/server/kibana-plugin-server.authresulttype.md b/docs/development/core/server/kibana-plugin-server.authresulttype.md index 61a98ee5e7b110..48c159a94c23d9 100644 --- a/docs/development/core/server/kibana-plugin-server.authresulttype.md +++ b/docs/development/core/server/kibana-plugin-server.authresulttype.md @@ -16,4 +16,6 @@ export declare enum AuthResultType | Member | Value | Description | | --- | --- | --- | | authenticated | "authenticated" | | +| notHandled | "notHandled" | | +| redirected | "redirected" | | diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md index bc7003c5a68f30..a6a30dae894ada 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -17,4 +17,6 @@ export interface AuthToolkit | Property | Type | Description | | --- | --- | --- | | [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (data?: AuthResultParams) => AuthResult | Authentication is successful with given credentials, allow request to pass through | +| [notHandled](./kibana-plugin-server.authtoolkit.nothandled.md) | () => AuthResult | User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true | +| [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | (headers: {
location: string;
} & ResponseHeaders) => AuthResult | Redirect user to IdP when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional' | diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.nothandled.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.nothandled.md new file mode 100644 index 00000000000000..7de174b3c7bb68 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.nothandled.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [notHandled](./kibana-plugin-server.authtoolkit.nothandled.md) + +## AuthToolkit.notHandled property + +User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true + +Signature: + +```typescript +notHandled: () => AuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md new file mode 100644 index 00000000000000..64d1d04a4abc0c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [redirected](./kibana-plugin-server.authtoolkit.redirected.md) + +## AuthToolkit.redirected property + +Redirect user to IdP when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional' + +Signature: + +```typescript +redirected: (headers: { + location: string; + } & ResponseHeaders) => AuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.auth.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.auth.md new file mode 100644 index 00000000000000..536d6bd04d9379 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.auth.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [auth](./kibana-plugin-server.kibanarequest.auth.md) + +## KibanaRequest.auth property + +Signature: + +```typescript +readonly auth: { + isAuthenticated: boolean; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.md index cb6745623e3818..0d520783fd4cf6 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -22,6 +22,7 @@ export declare class KibanaRequest{
isAuthenticated: boolean;
} | | | [body](./kibana-plugin-server.kibanarequest.body.md) | | Body | | | [events](./kibana-plugin-server.kibanarequest.events.md) | | KibanaRequestEvents | Request events [KibanaRequestEvents](./kibana-plugin-server.kibanarequestevents.md) | | [headers](./kibana-plugin-server.kibanarequest.headers.md) | | Headers | Readonly copy of incoming request headers. | diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index e843ffb265b826..c84585bf6cb650 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -53,7 +53,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AssistanceAPIResponse](./kibana-plugin-server.assistanceapiresponse.md) | | | [AssistantAPIClientParams](./kibana-plugin-server.assistantapiclientparams.md) | | | [Authenticated](./kibana-plugin-server.authenticated.md) | | -| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of an incoming request authentication. | +| [AuthNotHandled](./kibana-plugin-server.authnothandled.md) | | +| [AuthRedirected](./kibana-plugin-server.authredirected.md) | | +| [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) | Result of auth redirection. | +| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of successful authentication. | | [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. | | [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. | | [Capabilities](./kibana-plugin-server.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md index e4cbca9c978108..830abd4dde738c 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md @@ -4,12 +4,12 @@ ## RouteConfigOptions.authRequired property -A flag shows that authentication for a route: `enabled` when true `disabled` when false +Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible. -Enabled by default. +Defaults to `true` if an auth mechanism is registered. Signature: ```typescript -authRequired?: boolean; +authRequired?: boolean | 'optional'; ``` diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md index 7fbab90cc2c8a6..6664a28424a326 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md @@ -16,7 +16,7 @@ export interface RouteConfigOptions | Property | Type | Description | | --- | --- | --- | -| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | boolean | A flag shows that authentication for a route: enabled when true disabled when falseEnabled by default. | +| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | boolean | 'optional' | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.Defaults to true if an auth mechanism is registered. | | [body](./kibana-plugin-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | | [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index a9fa2bd18d3154..9a45fb9ab1d0c9 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -95,6 +95,8 @@ index for any pending Reporting jobs. Defaults to `3000` (3 seconds). [[xpack-reporting-q-timeout]]`xpack.reporting.queue.timeout`:: How long each worker has to produce a report. If your machine is slow or under heavy load, you might need to increase this timeout. Specified in milliseconds. +If a Reporting job execution time goes over this time limit, the job will be +marked as a failure and there will not be a download available. Defaults to `120000` (two minutes). [float] @@ -104,6 +106,26 @@ Defaults to `120000` (two minutes). Reporting works by capturing screenshots from Kibana. The following settings control the capturing process. +`xpack.reporting.capture.timeouts.openUrl`:: +How long to allow the Reporting browser to wait for the initial data of the +Kibana page to load. Defaults to `30000` (30 seconds). + +`xpack.reporting.capture.timeouts.waitForElements`:: +How long to allow the Reporting browser to wait for the visualization panels to +load on the Kibana page. Defaults to `30000` (30 seconds). + +`xpack.reporting.capture.timeouts.renderComplete`:: +How long to allow the Reporting brwoser to wait for each visualization to +signal that it is done renderings. Defaults to `30000` (30 seconds). + +[NOTE] +============ +If any timeouts from `xpack.reporting.capture.timeouts.*` settings occur when +running a report job, Reporting will log the error and try to continue +capturing the page with a screenshot. As a result, a download will be +available, but there will likely be errors in the visualizations in the report. +============ + `xpack.reporting.capture.maxAttempts`:: If capturing a report fails for any reason, Kibana will re-attempt othe reporting job, as many times as this setting. Defaults to `3`. diff --git a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap b/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap deleted file mode 100644 index 97e9082401b3da..00000000000000 --- a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]."`; - -exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; - -exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; - -exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; - -exports[`parsing units throws an error when unsupported unit specified 1`] = `"Failed to parse [1tb] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/byte_size_value/index.test.ts b/packages/kbn-config-schema/src/byte_size_value/index.test.ts index 198d95aa0ab4cc..59960a4567f604 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.test.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.test.ts @@ -42,19 +42,29 @@ describe('parsing units', () => { }); test('throws an error when unsupported unit specified', () => { - expect(() => ByteSizeValue.parse('1tb')).toThrowErrorMatchingSnapshot(); + expect(() => ByteSizeValue.parse('1tb')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse value as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."` + ); }); }); describe('#constructor', () => { test('throws if number of bytes is negative', () => { - expect(() => new ByteSizeValue(-1024)).toThrowErrorMatchingSnapshot(); + expect(() => new ByteSizeValue(-1024)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); }); test('throws if number of bytes is not safe', () => { - expect(() => new ByteSizeValue(NaN)).toThrowErrorMatchingSnapshot(); - expect(() => new ByteSizeValue(Infinity)).toThrowErrorMatchingSnapshot(); - expect(() => new ByteSizeValue(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + expect(() => new ByteSizeValue(NaN)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); + expect(() => new ByteSizeValue(Infinity)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); + expect(() => new ByteSizeValue(Math.pow(2, 53))).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); }); test('accepts 0', () => { diff --git a/packages/kbn-config-schema/src/byte_size_value/index.ts b/packages/kbn-config-schema/src/byte_size_value/index.ts index 48862821bb78d9..183a6c30f38390 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.ts @@ -38,7 +38,7 @@ export class ByteSizeValue { const number = Number(text); if (typeof number !== 'number' || isNaN(number)) { throw new Error( - `Failed to parse [${text}] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] ` + + `Failed to parse value as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] ` + `(e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer.` ); } @@ -53,9 +53,7 @@ export class ByteSizeValue { constructor(private readonly valueInBytes: number) { if (!Number.isSafeInteger(valueInBytes) || valueInBytes < 0) { - throw new Error( - `Value in bytes is expected to be a safe positive integer, but provided [${valueInBytes}].` - ); + throw new Error(`Value in bytes is expected to be a safe positive integer.`); } } diff --git a/packages/kbn-config-schema/src/duration/index.ts b/packages/kbn-config-schema/src/duration/index.ts index b96b5a3687bbb4..282c150e8150ac 100644 --- a/packages/kbn-config-schema/src/duration/index.ts +++ b/packages/kbn-config-schema/src/duration/index.ts @@ -28,7 +28,7 @@ function stringToDuration(text: string) { const number = Number(text); if (typeof number !== 'number' || isNaN(number)) { throw new Error( - `Failed to parse [${text}] as time value. Value must be a duration in milliseconds, or follow the format ` + + `Failed to parse value as time value. Value must be a duration in milliseconds, or follow the format ` + `[ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer.` ); } @@ -43,9 +43,7 @@ function stringToDuration(text: string) { function numberToDuration(numberMs: number) { if (!Number.isSafeInteger(numberMs) || numberMs < 0) { - throw new Error( - `Value in milliseconds is expected to be a safe positive integer, but provided [${numberMs}].` - ); + throw new Error(`Value in milliseconds is expected to be a safe positive integer.`); } return momentDuration(numberMs); diff --git a/packages/kbn-config-schema/src/types/__snapshots__/any_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/any_type.test.ts.snap deleted file mode 100644 index 3a40752d52b6ea..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/any_type.test.ts.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [any] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [any] but got [undefined]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap deleted file mode 100644 index 0e5f6de2deea8f..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [boolean] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [boolean] but got [undefined]"`; - -exports[`returns error when not boolean 1`] = `"expected value of type [boolean] but got [number]"`; - -exports[`returns error when not boolean 2`] = `"expected value of type [boolean] but got [Array]"`; - -exports[`returns error when not boolean 3`] = `"expected value of type [boolean] but got [string]"`; - -exports[`returns error when not boolean 4`] = `"expected value of type [boolean] but got [number]"`; - -exports[`returns error when not boolean 5`] = `"expected value of type [boolean] but got [string]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/buffer_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/buffer_type.test.ts.snap deleted file mode 100644 index 96a7ab34dac26a..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/buffer_type.test.ts.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [Buffer] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [Buffer] but got [undefined]"`; - -exports[`returns error when not a buffer 1`] = `"expected value of type [Buffer] but got [number]"`; - -exports[`returns error when not a buffer 2`] = `"expected value of type [Buffer] but got [Array]"`; - -exports[`returns error when not a buffer 3`] = `"expected value of type [Buffer] but got [string]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap deleted file mode 100644 index ea2102b1776fbf..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#defaultValue can be a ByteSizeValue 1`] = ` -ByteSizeValue { - "valueInBytes": 1024, -} -`; - -exports[`#defaultValue can be a number 1`] = ` -ByteSizeValue { - "valueInBytes": 1024, -} -`; - -exports[`#defaultValue can be a string 1`] = ` -ByteSizeValue { - "valueInBytes": 1024, -} -`; - -exports[`#defaultValue can be a string-formatted number 1`] = ` -ByteSizeValue { - "valueInBytes": 1024, -} -`; - -exports[`#max returns error when larger 1`] = `"Value is [1mb] ([1048576b]) but it must be equal to or less than [1kb]"`; - -exports[`#max returns value when smaller 1`] = ` -ByteSizeValue { - "valueInBytes": 1, -} -`; - -exports[`#min returns error when smaller 1`] = `"Value is [1b] ([1b]) but it must be equal to or greater than [1kb]"`; - -exports[`#min returns value when larger 1`] = ` -ByteSizeValue { - "valueInBytes": 1024, -} -`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [ByteSize] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [ByteSize] but got [undefined]"`; - -exports[`returns error when not valid string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]."`; - -exports[`returns error when not valid string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; - -exports[`returns error when not valid string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; - -exports[`returns error when not valid string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; - -exports[`returns error when not valid string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; - -exports[`returns error when not valid string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; - -exports[`returns error when not valid string or positive safe integer 7`] = `"Failed to parse [123foo] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; - -exports[`returns error when not valid string or positive safe integer 8`] = `"Failed to parse [123 456] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/conditional_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/conditional_type.test.ts.snap deleted file mode 100644 index b32db114860f5f..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/conditional_type.test.ts.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`correctly handles missing references 1`] = `"[value]: expected value of type [string] but got [number]"`; - -exports[`includes namespace into failures 1`] = `"[mega-namespace.value]: expected value of type [string] but got [number]"`; - -exports[`includes namespace into failures 2`] = `"[mega-namespace.value]: expected value of type [number] but got [string]"`; - -exports[`properly handles conditionals within objects 1`] = `"[value]: expected value of type [string] but got [number]"`; - -exports[`properly handles conditionals within objects 2`] = `"[value]: expected value of type [number] but got [string]"`; - -exports[`properly handles schemas with incompatible types 1`] = `"expected value of type [string] but got [boolean]"`; - -exports[`properly handles schemas with incompatible types 2`] = `"expected value of type [boolean] but got [string]"`; - -exports[`properly validates types according chosen schema 1`] = `"value is [a] but it must have a minimum length of [2]."`; - -exports[`properly validates types according chosen schema 2`] = `"value is [ab] but it must have a maximum length of [1]."`; - -exports[`properly validates when compares with "null" literal Schema 1`] = `"value is [a] but it must have a minimum length of [2]."`; - -exports[`properly validates when compares with "null" literal Schema 2`] = `"value is [ab] but it must have a minimum length of [3]."`; - -exports[`properly validates when compares with Schema 1`] = `"value is [a] but it must have a minimum length of [2]."`; - -exports[`properly validates when compares with Schema 2`] = `"value is [ab] but it must have a minimum length of [3]."`; - -exports[`required by default 1`] = `"expected value of type [string] but got [undefined]"`; - -exports[`works with both context and sibling references 1`] = `"[value]: expected value of type [string] but got [number]"`; - -exports[`works with both context and sibling references 2`] = `"[value]: expected value of type [number] but got [string]"`; - -exports[`works within \`oneOf\` 1`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [number] -- [1]: expected value of type [array] but got [number]" -`; - -exports[`works within \`oneOf\` 2`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [boolean] -- [1]: expected value of type [array] but got [boolean]" -`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap deleted file mode 100644 index c4e4ff652a2d7e..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#defaultValue can be a moment.Duration 1`] = `"PT1H"`; - -exports[`#defaultValue can be a number 1`] = `"PT0.6S"`; - -exports[`#defaultValue can be a string 1`] = `"PT1H"`; - -exports[`#defaultValue can be a string-formatted number 1`] = `"PT0.6S"`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [moment.Duration] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [moment.Duration] but got [undefined]"`; - -exports[`returns error when not valid string or non-safe positive integer 1`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [-123]."`; - -exports[`returns error when not valid string or non-safe positive integer 2`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [NaN]."`; - -exports[`returns error when not valid string or non-safe positive integer 3`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [Infinity]."`; - -exports[`returns error when not valid string or non-safe positive integer 4`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [9007199254740992]."`; - -exports[`returns error when not valid string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; - -exports[`returns error when not valid string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; - -exports[`returns error when not valid string or non-safe positive integer 7`] = `"Failed to parse [123foo] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; - -exports[`returns error when not valid string or non-safe positive integer 8`] = `"Failed to parse [123 456] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/literal_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/literal_type.test.ts.snap deleted file mode 100644 index 14d474b4a516bb..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/literal_type.test.ts.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value to equal [test] but got [foo]"`; - -exports[`returns error when not correct 1`] = `"expected value to equal [test] but got [foo]"`; - -exports[`returns error when not correct 2`] = `"expected value to equal [true] but got [false]"`; - -exports[`returns error when not correct 3`] = `"expected value to equal [test] but got [1,2,3]"`; - -exports[`returns error when not correct 4`] = `"expected value to equal [123] but got [abc]"`; - -exports[`returns error when not correct 5`] = `"expected value to equal [null] but got [42]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/maybe_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/maybe_type.test.ts.snap deleted file mode 100644 index fdb172df356a75..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/maybe_type.test.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`fails if null 1`] = `"expected value of type [string] but got [null]"`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [null]"`; - -exports[`validates basic type 1`] = `"expected value of type [string] but got [number]"`; - -exports[`validates contained type 1`] = `"value is [foo] but it must have a maximum length of [1]."`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/never_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/never_type.test.ts.snap deleted file mode 100644 index 6eea2a7cefc728..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/never_type.test.ts.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`throws on any value set 1`] = `"a value wasn't expected to be present"`; - -exports[`throws on any value set 2`] = `"a value wasn't expected to be present"`; - -exports[`throws on any value set 3`] = `"a value wasn't expected to be present"`; - -exports[`throws on any value set 4`] = `"a value wasn't expected to be present"`; - -exports[`throws on value set as object property 1`] = `"[name]: a value wasn't expected to be present"`; - -exports[`works for conditional types 1`] = `"[name]: a value wasn't expected to be present"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/nullable_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/nullable_type.test.ts.snap deleted file mode 100644 index 96ab664921fdfb..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/nullable_type.test.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = ` -"[foo-namespace]: types that failed validation: -- [foo-namespace.0]: value is [foo] but it must have a maximum length of [1]. -- [foo-namespace.1]: expected value to equal [null] but got [foo]" -`; - -exports[`validates basic type 1`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [number] -- [1]: expected value to equal [null] but got [666]" -`; - -exports[`validates contained type 1`] = ` -"types that failed validation: -- [0]: value is [foo] but it must have a maximum length of [1]. -- [1]: expected value to equal [null] but got [foo]" -`; - -exports[`validates type errors in object 1`] = ` -"[foo]: types that failed validation: -- [foo.0]: value is [ab] but it must have a maximum length of [1]. -- [foo.1]: expected value to equal [null] but got [ab]" -`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/number_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/number_type.test.ts.snap deleted file mode 100644 index 5d1e5fcf1ef817..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/number_type.test.ts.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#max returns error when larger number 1`] = `"Value is [3] but it must be equal to or lower than [2]."`; - -exports[`#min returns error when smaller number 1`] = `"Value is [3] but it must be equal to or greater than [4]."`; - -exports[`fails if number is \`NaN\` 1`] = `"expected value of type [number] but got [number]"`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [number] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [number] but got [undefined]"`; - -exports[`returns error when not number or numeric string 1`] = `"expected value of type [number] but got [string]"`; - -exports[`returns error when not number or numeric string 2`] = `"expected value of type [number] but got [Array]"`; - -exports[`returns error when not number or numeric string 3`] = `"expected value of type [number] but got [RegExp]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/one_of_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/one_of_type.test.ts.snap deleted file mode 100644 index 75dfff456ebe7c..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/one_of_type.test.ts.snap +++ /dev/null @@ -1,32 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`fails if not matching literal 1`] = ` -"types that failed validation: -- [0]: expected value to equal [foo] but got [bar]" -`; - -exports[`fails if not matching multiple types 1`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [boolean] -- [1]: expected value of type [number] but got [boolean]" -`; - -exports[`fails if not matching type 1`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [boolean]" -`; - -exports[`fails if not matching type 2`] = ` -"types that failed validation: -- [0]: expected value of type [string] but got [number]" -`; - -exports[`handles object with wrong type 1`] = ` -"types that failed validation: -- [0.age]: expected value of type [number] but got [string]" -`; - -exports[`includes namespace in failure 1`] = ` -"[foo-namespace]: types that failed validation: -- [foo-namespace.0.age]: expected value of type [number] but got [string]" -`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/stream_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/stream_type.test.ts.snap deleted file mode 100644 index e813b4f68a09e1..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/stream_type.test.ts.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [Stream] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [Buffer] but got [undefined]"`; - -exports[`returns error when not a stream 1`] = `"expected value of type [Stream] but got [number]"`; - -exports[`returns error when not a stream 2`] = `"expected value of type [Stream] but got [Array]"`; - -exports[`returns error when not a stream 3`] = `"expected value of type [Stream] but got [string]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/string_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/string_type.test.ts.snap deleted file mode 100644 index 8e1f63fb66733e..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/string_type.test.ts.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#hostname returns error when empty string 1`] = `"any.empty"`; - -exports[`#hostname returns error when value is not a valid hostname 1`] = `"value is [host:name] but it must be a valid hostname (see RFC 1123)."`; - -exports[`#hostname returns error when value is not a valid hostname 2`] = `"value is [localhost:5601] but it must be a valid hostname (see RFC 1123)."`; - -exports[`#hostname returns error when value is not a valid hostname 3`] = `"value is [-] but it must be a valid hostname (see RFC 1123)."`; - -exports[`#hostname returns error when value is not a valid hostname 4`] = `"value is [0:?:0:0:0:0:0:1] but it must be a valid hostname (see RFC 1123)."`; - -exports[`#hostname returns error when value is not a valid hostname 5`] = `"value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must be a valid hostname (see RFC 1123)."`; - -exports[`#hostname supports string validation rules 1`] = `"value is [www.example.com] but it must have a maximum length of [3]."`; - -exports[`#maxLength returns error when longer string 1`] = `"value is [foo] but it must have a maximum length of [2]."`; - -exports[`#minLength returns error when empty string 1`] = `"value is [] but it must have a minimum length of [2]."`; - -exports[`#minLength returns error when shorter string 1`] = `"value is [foo] but it must have a minimum length of [4]."`; - -exports[`#validate throw when empty string 1`] = `"validator failure"`; - -exports[`#validate throws when returns string 1`] = `"validator failure"`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [string] but got [undefined]"`; - -exports[`returns error when not string 1`] = `"expected value of type [string] but got [number]"`; - -exports[`returns error when not string 2`] = `"expected value of type [string] but got [Array]"`; - -exports[`returns error when not string 3`] = `"expected value of type [string] but got [RegExp]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/uri_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/uri_type.test.ts.snap deleted file mode 100644 index 81fafdb4a7b339..00000000000000 --- a/packages/kbn-config-schema/src/types/__snapshots__/uri_type.test.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#scheme returns error when shorter string 1`] = `"expected URI with scheme [http|https] but got [ftp://elastic.co]."`; - -exports[`#scheme returns error when shorter string 2`] = `"expected URI with scheme [http|https] but got [file:///kibana.log]."`; - -exports[`#validate throws when returns string 1`] = `"validator failure"`; - -exports[`is required by default 1`] = `"expected value of type [string] but got [undefined]."`; - -exports[`returns error when not string 1`] = `"expected value of type [string] but got [number]."`; - -exports[`returns error when not string 2`] = `"expected value of type [string] but got [Array]."`; - -exports[`returns error when not string 3`] = `"expected value of type [string] but got [RegExp]."`; - -exports[`returns error when value is not a URI 1`] = `"value is [3domain.local] but it must be a valid URI (see RFC 3986)."`; - -exports[`returns error when value is not a URI 2`] = `"value is [http://8010:0:0:0:9:500:300C:200A] but it must be a valid URI (see RFC 3986)."`; - -exports[`returns error when value is not a URI 3`] = `"value is [-] but it must be a valid URI (see RFC 3986)."`; - -exports[`returns error when value is not a URI 4`] = `"value is [https://example.com?baz[]=foo&baz[]=bar] but it must be a valid URI (see RFC 3986)."`; - -exports[`returns error when value is not a URI 5`] = `"value is [http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must be a valid URI (see RFC 3986)."`; diff --git a/packages/kbn-config-schema/src/types/any_type.test.ts b/packages/kbn-config-schema/src/types/any_type.test.ts index 4d68c860ba13d3..30a9a8b71ea120 100644 --- a/packages/kbn-config-schema/src/types/any_type.test.ts +++ b/packages/kbn-config-schema/src/types/any_type.test.ts @@ -28,13 +28,17 @@ test('works for any value', () => { }); test('is required by default', () => { - expect(() => schema.any().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.any().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [any] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.any().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [any] but got [undefined]"` + ); }); describe('#defaultValue', () => { diff --git a/packages/kbn-config-schema/src/types/array_type.test.ts b/packages/kbn-config-schema/src/types/array_type.test.ts index 66b72096a593da..9f3370de8c2659 100644 --- a/packages/kbn-config-schema/src/types/array_type.test.ts +++ b/packages/kbn-config-schema/src/types/array_type.test.ts @@ -39,7 +39,7 @@ test('fails if wrong input type', () => { test('fails if string input cannot be parsed', () => { const type = schema.arrayOf(schema.string()); expect(() => type.validate('test')).toThrowErrorMatchingInlineSnapshot( - `"could not parse array value from [test]"` + `"could not parse array value from json input"` ); }); @@ -53,7 +53,7 @@ test('fails with correct type if parsed input is not an array', () => { test('includes namespace in failure when wrong top-level type', () => { const type = schema.arrayOf(schema.string()); expect(() => type.validate('test', {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( - `"[foo-namespace]: could not parse array value from [test]"` + `"[foo-namespace]: could not parse array value from json input"` ); }); diff --git a/packages/kbn-config-schema/src/types/array_type.ts b/packages/kbn-config-schema/src/types/array_type.ts index a0353e8348ddd6..0df0d44a37951f 100644 --- a/packages/kbn-config-schema/src/types/array_type.ts +++ b/packages/kbn-config-schema/src/types/array_type.ts @@ -52,7 +52,7 @@ export class ArrayType extends Type { case 'array.sparse': return `sparse array are not allowed`; case 'array.parse': - return `could not parse array value from [${value}]`; + return `could not parse array value from json input`; case 'array.min': return `array size is [${value.length}], but cannot be smaller than [${limit}]`; case 'array.max': diff --git a/packages/kbn-config-schema/src/types/boolean_type.test.ts b/packages/kbn-config-schema/src/types/boolean_type.test.ts index e94999b5054377..bffa3e18f93bfc 100644 --- a/packages/kbn-config-schema/src/types/boolean_type.test.ts +++ b/packages/kbn-config-schema/src/types/boolean_type.test.ts @@ -35,13 +35,17 @@ test('handles boolean strings', () => { }); test('is required by default', () => { - expect(() => schema.boolean().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.boolean().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [boolean] but got [undefined]"` + ); }); describe('#defaultValue', () => { @@ -55,13 +59,23 @@ describe('#defaultValue', () => { }); test('returns error when not boolean', () => { - expect(() => schema.boolean().validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate(123)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [number]"` + ); - expect(() => schema.boolean().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [Array]"` + ); - expect(() => schema.boolean().validate('abc')).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate('abc')).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [string]"` + ); - expect(() => schema.boolean().validate(0)).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate(0)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [number]"` + ); - expect(() => schema.boolean().validate('no')).toThrowErrorMatchingSnapshot(); + expect(() => schema.boolean().validate('no')).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [boolean] but got [string]"` + ); }); diff --git a/packages/kbn-config-schema/src/types/buffer_type.test.ts b/packages/kbn-config-schema/src/types/buffer_type.test.ts index 63d59296aec843..a7b589df0c6d10 100644 --- a/packages/kbn-config-schema/src/types/buffer_type.test.ts +++ b/packages/kbn-config-schema/src/types/buffer_type.test.ts @@ -25,13 +25,17 @@ test('returns value by default', () => { }); test('is required by default', () => { - expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Buffer] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.buffer().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [Buffer] but got [undefined]"` + ); }); describe('#defaultValue', () => { @@ -49,9 +53,15 @@ describe('#defaultValue', () => { }); test('returns error when not a buffer', () => { - expect(() => schema.buffer().validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => schema.buffer().validate(123)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Buffer] but got [number]"` + ); - expect(() => schema.buffer().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.buffer().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Buffer] but got [Array]"` + ); - expect(() => schema.buffer().validate('abc')).toThrowErrorMatchingSnapshot(); + expect(() => schema.buffer().validate('abc')).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Buffer] but got [string]"` + ); }); diff --git a/packages/kbn-config-schema/src/types/byte_size_type.test.ts b/packages/kbn-config-schema/src/types/byte_size_type.test.ts index 7c65ec2945b493..a69a7296a6eb82 100644 --- a/packages/kbn-config-schema/src/types/byte_size_type.test.ts +++ b/packages/kbn-config-schema/src/types/byte_size_type.test.ts @@ -35,11 +35,17 @@ test('handles numbers', () => { }); test('is required by default', () => { - expect(() => byteSize().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [ByteSize] but got [undefined]"` + ); }); test('includes namespace in failure', () => { - expect(() => byteSize().validate(undefined, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => + byteSize().validate(undefined, {}, 'foo-namespace') + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [ByteSize] but got [undefined]"` + ); }); describe('#defaultValue', () => { @@ -48,7 +54,11 @@ describe('#defaultValue', () => { byteSize({ defaultValue: ByteSizeValue.parse('1kb'), }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1024, + } + `); }); test('can be a string', () => { @@ -56,7 +66,11 @@ describe('#defaultValue', () => { byteSize({ defaultValue: '1kb', }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1024, + } + `); }); test('can be a string-formatted number', () => { @@ -64,7 +78,11 @@ describe('#defaultValue', () => { byteSize({ defaultValue: '1024', }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1024, + } + `); }); test('can be a number', () => { @@ -72,7 +90,11 @@ describe('#defaultValue', () => { byteSize({ defaultValue: 1024, }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1024, + } + `); }); }); @@ -82,7 +104,11 @@ describe('#min', () => { byteSize({ min: '1b', }).validate('1kb') - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1024, + } + `); }); test('returns error when smaller', () => { @@ -90,34 +116,56 @@ describe('#min', () => { byteSize({ min: '1kb', }).validate('1b') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"Value must be equal to or greater than [1kb]"`); }); }); describe('#max', () => { test('returns value when smaller', () => { - expect(byteSize({ max: '1kb' }).validate('1b')).toMatchSnapshot(); + expect(byteSize({ max: '1kb' }).validate('1b')).toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 1, + } + `); }); test('returns error when larger', () => { - expect(() => byteSize({ max: '1kb' }).validate('1mb')).toThrowErrorMatchingSnapshot(); + expect(() => byteSize({ max: '1kb' }).validate('1mb')).toThrowErrorMatchingInlineSnapshot( + `"Value must be equal to or less than [1kb]"` + ); }); }); test('returns error when not valid string or positive safe integer', () => { - expect(() => byteSize().validate(-123)).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(-123)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); - expect(() => byteSize().validate(NaN)).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(NaN)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); - expect(() => byteSize().validate(Infinity)).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(Infinity)).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); - expect(() => byteSize().validate(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(Math.pow(2, 53))).toThrowErrorMatchingInlineSnapshot( + `"Value in bytes is expected to be a safe positive integer."` + ); - expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [ByteSize] but got [Array]"` + ); - expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [ByteSize] but got [RegExp]"` + ); - expect(() => byteSize().validate('123foo')).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate('123foo')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse value as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."` + ); - expect(() => byteSize().validate('123 456')).toThrowErrorMatchingSnapshot(); + expect(() => byteSize().validate('123 456')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse value as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."` + ); }); diff --git a/packages/kbn-config-schema/src/types/byte_size_type.ts b/packages/kbn-config-schema/src/types/byte_size_type.ts index 4833de7ecf15f9..f7aa12291c7de3 100644 --- a/packages/kbn-config-schema/src/types/byte_size_type.ts +++ b/packages/kbn-config-schema/src/types/byte_size_type.ts @@ -61,13 +61,9 @@ export class ByteSizeType extends Type { case 'bytes.parse': return new SchemaTypeError(message, path); case 'bytes.min': - return `Value is [${value.toString()}] ([${value.toString( - 'b' - )}]) but it must be equal to or greater than [${limit.toString()}]`; + return `Value must be equal to or greater than [${limit.toString()}]`; case 'bytes.max': - return `Value is [${value.toString()}] ([${value.toString( - 'b' - )}]) but it must be equal to or less than [${limit.toString()}]`; + return `Value must be equal to or less than [${limit.toString()}]`; } } } diff --git a/packages/kbn-config-schema/src/types/conditional_type.test.ts b/packages/kbn-config-schema/src/types/conditional_type.test.ts index 354854b864755c..b7ad431318e858 100644 --- a/packages/kbn-config-schema/src/types/conditional_type.test.ts +++ b/packages/kbn-config-schema/src/types/conditional_type.test.ts @@ -32,7 +32,7 @@ test('required by default', () => { context_value_1: 0, context_value_2: 0, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"expected value of type [string] but got [undefined]"`); }); test('returns default', () => { @@ -90,7 +90,9 @@ test('properly validates types according chosen schema', () => { context_value_1: 0, context_value_2: 0, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [1] but it must have a minimum length of [2]."` + ); expect( type.validate('ab', { @@ -104,7 +106,9 @@ test('properly validates types according chosen schema', () => { context_value_1: 0, context_value_2: 1, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [2] but it must have a maximum length of [1]."` + ); expect( type.validate('a', { @@ -126,7 +130,9 @@ test('properly validates when compares with Schema', () => { type.validate('a', { context_value_1: 0, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [1] but it must have a minimum length of [2]."` + ); expect( type.validate('ab', { @@ -138,7 +144,9 @@ test('properly validates when compares with Schema', () => { type.validate('ab', { context_value_1: 'b', }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [2] but it must have a minimum length of [3]."` + ); expect( type.validate('abc', { @@ -159,7 +167,9 @@ test('properly validates when compares with "null" literal Schema', () => { type.validate('a', { context_value_1: null, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [1] but it must have a minimum length of [2]."` + ); expect( type.validate('ab', { @@ -171,7 +181,9 @@ test('properly validates when compares with "null" literal Schema', () => { type.validate('ab', { context_value_1: 'b', }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [2] but it must have a minimum length of [3]."` + ); expect( type.validate('abc', { @@ -193,7 +205,7 @@ test('properly handles schemas with incompatible types', () => { context_value_1: 0, context_value_2: 0, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"expected value of type [string] but got [boolean]"`); expect( type.validate('a', { @@ -207,7 +219,7 @@ test('properly handles schemas with incompatible types', () => { context_value_1: 0, context_value_2: 1, }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"expected value of type [boolean] but got [string]"`); expect( type.validate(true, { @@ -223,14 +235,18 @@ test('properly handles conditionals within objects', () => { value: schema.conditional(schema.siblingRef('key'), 'number', schema.number(), schema.string()), }); - expect(() => type.validate({ key: 'string', value: 1 })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ key: 'string', value: 1 })).toThrowErrorMatchingInlineSnapshot( + `"[value]: expected value of type [string] but got [number]"` + ); expect(type.validate({ key: 'string', value: 'a' })).toEqual({ key: 'string', value: 'a', }); - expect(() => type.validate({ key: 'number', value: 'a' })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ key: 'number', value: 'a' })).toThrowErrorMatchingInlineSnapshot( + `"[value]: expected value of type [number] but got [string]"` + ); expect(type.validate({ key: 'number', value: 1 })).toEqual({ key: 'number', @@ -269,7 +285,9 @@ test('works with both context and sibling references', () => { expect(() => type.validate({ key: 'string', value: 1 }, { context_key: 'number' }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[value]: expected value of type [string] but got [number]"` + ); expect(type.validate({ key: 'string', value: 'a' }, { context_key: 'number' })).toEqual({ key: 'string', @@ -278,7 +296,9 @@ test('works with both context and sibling references', () => { expect(() => type.validate({ key: 'number', value: 'a' }, { context_key: 'number' }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[value]: expected value of type [number] but got [string]"` + ); expect(type.validate({ key: 'number', value: 1 }, { context_key: 'number' })).toEqual({ key: 'number', @@ -294,11 +314,15 @@ test('includes namespace into failures', () => { expect(() => type.validate({ key: 'string', value: 1 }, {}, 'mega-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[mega-namespace.value]: expected value of type [string] but got [number]"` + ); expect(() => type.validate({ key: 'number', value: 'a' }, {}, 'mega-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[mega-namespace.value]: expected value of type [number] but got [string]"` + ); }); test('correctly handles missing references', () => { @@ -311,7 +335,9 @@ test('correctly handles missing references', () => { ), }); - expect(() => type.validate({ value: 1 })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ value: 1 })).toThrowErrorMatchingInlineSnapshot( + `"[value]: expected value of type [string] but got [number]"` + ); expect(type.validate({ value: 'a' })).toEqual({ value: 'a' }); }); @@ -332,8 +358,16 @@ test('works within `oneOf`', () => { expect(type.validate(true, { type: 'boolean' })).toEqual(true); expect(type.validate(['a', 'b'], { type: 'array' })).toEqual(['a', 'b']); - expect(() => type.validate(1, { type: 'string' })).toThrowErrorMatchingSnapshot(); - expect(() => type.validate(true, { type: 'string' })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(1, { type: 'string' })).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [number] +- [1]: expected value of type [array] but got [number]" +`); + expect(() => type.validate(true, { type: 'string' })).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [boolean] +- [1]: expected value of type [array] but got [boolean]" +`); }); describe('#validate', () => { diff --git a/packages/kbn-config-schema/src/types/duration_type.test.ts b/packages/kbn-config-schema/src/types/duration_type.test.ts index 57e917dc99b2b3..2a0458f1419cc5 100644 --- a/packages/kbn-config-schema/src/types/duration_type.test.ts +++ b/packages/kbn-config-schema/src/types/duration_type.test.ts @@ -35,11 +35,17 @@ test('handles number', () => { }); test('is required by default', () => { - expect(() => duration().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [moment.Duration] but got [undefined]"` + ); }); test('includes namespace in failure', () => { - expect(() => duration().validate(undefined, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => + duration().validate(undefined, {}, 'foo-namespace') + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [moment.Duration] but got [undefined]"` + ); }); describe('#defaultValue', () => { @@ -48,7 +54,7 @@ describe('#defaultValue', () => { duration({ defaultValue: momentDuration(1, 'hour'), }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(`"PT1H"`); }); test('can be a string', () => { @@ -56,7 +62,7 @@ describe('#defaultValue', () => { duration({ defaultValue: '1h', }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(`"PT1H"`); }); test('can be a string-formatted number', () => { @@ -64,7 +70,7 @@ describe('#defaultValue', () => { duration({ defaultValue: '600', }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(`"PT0.6S"`); }); test('can be a number', () => { @@ -72,7 +78,7 @@ describe('#defaultValue', () => { duration({ defaultValue: 600, }).validate(undefined) - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(`"PT0.6S"`); }); test('can be a function that returns compatible type', () => { @@ -103,12 +109,12 @@ describe('#defaultValue', () => { fromContext: duration({ defaultValue: contextRef('val') }), }).validate({}, { val: momentDuration(700, 'ms') }) ).toMatchInlineSnapshot(` -Object { - "fromContext": "PT0.7S", - "source": "PT0.6S", - "target": "PT0.6S", -} -`); + Object { + "fromContext": "PT0.7S", + "source": "PT0.6S", + "target": "PT0.6S", + } + `); expect( object({ @@ -117,12 +123,12 @@ Object { fromContext: duration({ defaultValue: contextRef('val') }), }).validate({}, { val: momentDuration(2, 'hour') }) ).toMatchInlineSnapshot(` -Object { - "fromContext": "PT2H", - "source": "PT1H", - "target": "PT1H", -} -`); + Object { + "fromContext": "PT2H", + "source": "PT1H", + "target": "PT1H", + } + `); expect( object({ @@ -131,29 +137,45 @@ Object { fromContext: duration({ defaultValue: contextRef('val') }), }).validate({}, { val: momentDuration(2, 'hour') }) ).toMatchInlineSnapshot(` -Object { - "fromContext": "PT2H", - "source": "PT1H", - "target": "PT1H", -} -`); + Object { + "fromContext": "PT2H", + "source": "PT1H", + "target": "PT1H", + } + `); }); }); test('returns error when not valid string or non-safe positive integer', () => { - expect(() => duration().validate(-123)).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(-123)).toThrowErrorMatchingInlineSnapshot( + `"Value in milliseconds is expected to be a safe positive integer."` + ); - expect(() => duration().validate(NaN)).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(NaN)).toThrowErrorMatchingInlineSnapshot( + `"Value in milliseconds is expected to be a safe positive integer."` + ); - expect(() => duration().validate(Infinity)).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(Infinity)).toThrowErrorMatchingInlineSnapshot( + `"Value in milliseconds is expected to be a safe positive integer."` + ); - expect(() => duration().validate(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(Math.pow(2, 53))).toThrowErrorMatchingInlineSnapshot( + `"Value in milliseconds is expected to be a safe positive integer."` + ); - expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [moment.Duration] but got [Array]"` + ); - expect(() => duration().validate(/abc/)).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate(/abc/)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [moment.Duration] but got [RegExp]"` + ); - expect(() => duration().validate('123foo')).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate('123foo')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse value as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."` + ); - expect(() => duration().validate('123 456')).toThrowErrorMatchingSnapshot(); + expect(() => duration().validate('123 456')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse value as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."` + ); }); diff --git a/packages/kbn-config-schema/src/types/literal_type.test.ts b/packages/kbn-config-schema/src/types/literal_type.test.ts index a5ddff3152368f..abcf5bb2a3b2d8 100644 --- a/packages/kbn-config-schema/src/types/literal_type.test.ts +++ b/packages/kbn-config-schema/src/types/literal_type.test.ts @@ -38,17 +38,29 @@ test('handles null', () => { }); test('returns error when not correct', () => { - expect(() => literal('test').validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => literal('test').validate('foo')).toThrowErrorMatchingInlineSnapshot( + `"expected value to equal [test]"` + ); - expect(() => literal(true).validate(false)).toThrowErrorMatchingSnapshot(); + expect(() => literal(true).validate(false)).toThrowErrorMatchingInlineSnapshot( + `"expected value to equal [true]"` + ); - expect(() => literal('test').validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => literal('test').validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value to equal [test]"` + ); - expect(() => literal(123).validate('abc')).toThrowErrorMatchingSnapshot(); + expect(() => literal(123).validate('abc')).toThrowErrorMatchingInlineSnapshot( + `"expected value to equal [123]"` + ); - expect(() => literal(null).validate(42)).toThrowErrorMatchingSnapshot(); + expect(() => literal(null).validate(42)).toThrowErrorMatchingInlineSnapshot( + `"expected value to equal [null]"` + ); }); test('includes namespace in failure', () => { - expect(() => literal('test').validate('foo', {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => + literal('test').validate('foo', {}, 'foo-namespace') + ).toThrowErrorMatchingInlineSnapshot(`"[foo-namespace]: expected value to equal [test]"`); }); diff --git a/packages/kbn-config-schema/src/types/literal_type.ts b/packages/kbn-config-schema/src/types/literal_type.ts index b5ddaa2f68d4f6..5ba0b417683bd2 100644 --- a/packages/kbn-config-schema/src/types/literal_type.ts +++ b/packages/kbn-config-schema/src/types/literal_type.ts @@ -29,7 +29,7 @@ export class LiteralType extends Type { switch (type) { case 'any.required': case 'any.allowOnly': - return `expected value to equal [${expectedValue}] but got [${value}]`; + return `expected value to equal [${expectedValue}]`; } } } diff --git a/packages/kbn-config-schema/src/types/map_of_type.test.ts b/packages/kbn-config-schema/src/types/map_of_type.test.ts index 3cb3d2d0b68622..b015f51bdc8ad9 100644 --- a/packages/kbn-config-schema/src/types/map_of_type.test.ts +++ b/packages/kbn-config-schema/src/types/map_of_type.test.ts @@ -40,7 +40,7 @@ test('properly parse the value if input is a string', () => { test('fails if string input cannot be parsed', () => { const type = schema.mapOf(schema.string(), schema.string()); expect(() => type.validate(`invalidjson`)).toThrowErrorMatchingInlineSnapshot( - `"could not parse map value from [invalidjson]"` + `"could not parse map value from json input"` ); }); @@ -169,7 +169,7 @@ test('error preserves full path', () => { expect(() => type.validate({ grandParentKey: { parentKey: { a: 'some-value' } } }) ).toThrowErrorMatchingInlineSnapshot( - `"[grandParentKey.parentKey.key(\\"a\\")]: value is [a] but it must have a minimum length of [2]."` + `"[grandParentKey.parentKey.key(\\"a\\")]: value has length [1] but it must have a minimum length of [2]."` ); expect(() => diff --git a/packages/kbn-config-schema/src/types/map_type.ts b/packages/kbn-config-schema/src/types/map_type.ts index 1c0c473f98ec14..231c3726ae9d57 100644 --- a/packages/kbn-config-schema/src/types/map_type.ts +++ b/packages/kbn-config-schema/src/types/map_type.ts @@ -49,7 +49,7 @@ export class MapOfType extends Type> { case 'map.base': return `expected value of type [Map] or [object] but got [${typeDetect(value)}]`; case 'map.parse': - return `could not parse map value from [${value}]`; + return `could not parse map value from json input`; case 'map.key': case 'map.value': const childPathWithIndex = path.slice(); diff --git a/packages/kbn-config-schema/src/types/maybe_type.test.ts b/packages/kbn-config-schema/src/types/maybe_type.test.ts index c35fa18593520a..2a1278f5e801cf 100644 --- a/packages/kbn-config-schema/src/types/maybe_type.test.ts +++ b/packages/kbn-config-schema/src/types/maybe_type.test.ts @@ -42,23 +42,31 @@ test('returns undefined even if contained type has a default value', () => { test('validates contained type', () => { const type = schema.maybe(schema.string({ maxLength: 1 })); - expect(() => type.validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate('foo')).toThrowErrorMatchingInlineSnapshot( + `"value has length [3] but it must have a maximum length of [1]."` + ); }); test('validates basic type', () => { const type = schema.maybe(schema.string()); - expect(() => type.validate(666)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(666)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [number]"` + ); }); test('fails if null', () => { const type = schema.maybe(schema.string()); - expect(() => type.validate(null)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(null)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [null]"` + ); }); test('includes namespace in failure', () => { const type = schema.maybe(schema.string()); - expect(() => type.validate(null, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(null, {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [string] but got [null]"` + ); }); describe('maybe + object', () => { diff --git a/packages/kbn-config-schema/src/types/never_type.test.ts b/packages/kbn-config-schema/src/types/never_type.test.ts index 46f0b47f56ad60..2e5bc0e8c672df 100644 --- a/packages/kbn-config-schema/src/types/never_type.test.ts +++ b/packages/kbn-config-schema/src/types/never_type.test.ts @@ -22,10 +22,18 @@ import { schema } from '..'; test('throws on any value set', () => { const type = schema.never(); - expect(() => type.validate(1)).toThrowErrorMatchingSnapshot(); - expect(() => type.validate('a')).toThrowErrorMatchingSnapshot(); - expect(() => type.validate(null)).toThrowErrorMatchingSnapshot(); - expect(() => type.validate({})).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(1)).toThrowErrorMatchingInlineSnapshot( + `"a value wasn't expected to be present"` + ); + expect(() => type.validate('a')).toThrowErrorMatchingInlineSnapshot( + `"a value wasn't expected to be present"` + ); + expect(() => type.validate(null)).toThrowErrorMatchingInlineSnapshot( + `"a value wasn't expected to be present"` + ); + expect(() => type.validate({})).toThrowErrorMatchingInlineSnapshot( + `"a value wasn't expected to be present"` + ); expect(() => type.validate(undefined)).not.toThrow(); }); @@ -37,7 +45,7 @@ test('throws on value set as object property', () => { expect(() => type.validate({ name: 'name', status: 'in progress' }) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"[name]: a value wasn't expected to be present"`); expect(() => type.validate({ status: 'in progress' })).not.toThrow(); expect(() => type.validate({ name: undefined, status: 'in progress' })).not.toThrow(); @@ -71,5 +79,5 @@ test('works for conditional types', () => { context_value_2: 1, } ) - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"[name]: a value wasn't expected to be present"`); }); diff --git a/packages/kbn-config-schema/src/types/nullable_type.test.ts b/packages/kbn-config-schema/src/types/nullable_type.test.ts index ed04202950a2c9..fb9d544a3eb0eb 100644 --- a/packages/kbn-config-schema/src/types/nullable_type.test.ts +++ b/packages/kbn-config-schema/src/types/nullable_type.test.ts @@ -94,13 +94,21 @@ test('returns null even if contained type has a default value', () => { test('validates contained type', () => { const type = schema.nullable(schema.string({ maxLength: 1 })); - expect(() => type.validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate('foo')).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: value has length [3] but it must have a maximum length of [1]. +- [1]: expected value to equal [null]" +`); }); test('validates basic type', () => { const type = schema.nullable(schema.string()); - expect(() => type.validate(666)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(666)).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [number] +- [1]: expected value to equal [null]" +`); }); test('validates type in object', () => { @@ -121,11 +129,19 @@ test('validates type errors in object', () => { bar: schema.nullable(schema.boolean()), }); - expect(() => type.validate({ foo: 'ab' })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ foo: 'ab' })).toThrowErrorMatchingInlineSnapshot(` +"[foo]: types that failed validation: +- [foo.0]: value has length [2] but it must have a maximum length of [1]. +- [foo.1]: expected value to equal [null]" +`); }); test('includes namespace in failure', () => { const type = schema.nullable(schema.string({ maxLength: 1 })); - expect(() => type.validate('foo', {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate('foo', {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot(` +"[foo-namespace]: types that failed validation: +- [foo-namespace.0]: value has length [3] but it must have a maximum length of [1]. +- [foo-namespace.1]: expected value to equal [null]" +`); }); diff --git a/packages/kbn-config-schema/src/types/number_type.test.ts b/packages/kbn-config-schema/src/types/number_type.test.ts index b85d5113563eb6..cfcb0e99afbd5c 100644 --- a/packages/kbn-config-schema/src/types/number_type.test.ts +++ b/packages/kbn-config-schema/src/types/number_type.test.ts @@ -32,17 +32,23 @@ test('handles numeric strings with floats', () => { }); test('fails if number is `NaN`', () => { - expect(() => schema.number().validate(NaN)).toThrowErrorMatchingSnapshot(); + expect(() => schema.number().validate(NaN)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [number] but got [number]"` + ); }); test('is required by default', () => { - expect(() => schema.number().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.number().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [number] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.number().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [number] but got [undefined]"` + ); }); describe('#min', () => { @@ -51,7 +57,9 @@ describe('#min', () => { }); test('returns error when smaller number', () => { - expect(() => schema.number({ min: 4 }).validate(3)).toThrowErrorMatchingSnapshot(); + expect(() => schema.number({ min: 4 }).validate(3)).toThrowErrorMatchingInlineSnapshot( + `"Value must be equal to or greater than [4]."` + ); }); }); @@ -61,7 +69,9 @@ describe('#max', () => { }); test('returns error when larger number', () => { - expect(() => schema.number({ max: 2 }).validate(3)).toThrowErrorMatchingSnapshot(); + expect(() => schema.number({ max: 2 }).validate(3)).toThrowErrorMatchingInlineSnapshot( + `"Value must be equal to or lower than [2]."` + ); }); }); @@ -76,9 +86,15 @@ describe('#defaultValue', () => { }); test('returns error when not number or numeric string', () => { - expect(() => schema.number().validate('test')).toThrowErrorMatchingSnapshot(); + expect(() => schema.number().validate('test')).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [number] but got [string]"` + ); - expect(() => schema.number().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.number().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [number] but got [Array]"` + ); - expect(() => schema.number().validate(/abc/)).toThrowErrorMatchingSnapshot(); + expect(() => schema.number().validate(/abc/)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [number] but got [RegExp]"` + ); }); diff --git a/packages/kbn-config-schema/src/types/number_type.ts b/packages/kbn-config-schema/src/types/number_type.ts index ada4d1909c9178..81ca4449122a3a 100644 --- a/packages/kbn-config-schema/src/types/number_type.ts +++ b/packages/kbn-config-schema/src/types/number_type.ts @@ -46,9 +46,9 @@ export class NumberType extends Type { case 'number.base': return `expected value of type [number] but got [${typeDetect(value)}]`; case 'number.min': - return `Value is [${value}] but it must be equal to or greater than [${limit}].`; + return `Value must be equal to or greater than [${limit}].`; case 'number.max': - return `Value is [${value}] but it must be equal to or lower than [${limit}].`; + return `Value must be equal to or lower than [${limit}].`; } } } diff --git a/packages/kbn-config-schema/src/types/object_type.test.ts b/packages/kbn-config-schema/src/types/object_type.test.ts index 64739d7a4c4daa..29e341983fde9d 100644 --- a/packages/kbn-config-schema/src/types/object_type.test.ts +++ b/packages/kbn-config-schema/src/types/object_type.test.ts @@ -49,7 +49,7 @@ test('fails if string input cannot be parsed', () => { name: schema.string(), }); expect(() => type.validate(`invalidjson`)).toThrowErrorMatchingInlineSnapshot( - `"could not parse object value from [invalidjson]"` + `"could not parse object value from json input"` ); }); @@ -181,7 +181,7 @@ test('called with wrong type', () => { const type = schema.object({}); expect(() => type.validate('foo')).toThrowErrorMatchingInlineSnapshot( - `"could not parse object value from [foo]"` + `"could not parse object value from json input"` ); expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot( `"expected a plain object value, but found [number] instead."` diff --git a/packages/kbn-config-schema/src/types/object_type.ts b/packages/kbn-config-schema/src/types/object_type.ts index 4f3d68a6bac97d..f34acd0d2ce656 100644 --- a/packages/kbn-config-schema/src/types/object_type.ts +++ b/packages/kbn-config-schema/src/types/object_type.ts @@ -62,7 +62,7 @@ export class ObjectType

extends Type> case 'object.base': return `expected a plain object value, but found [${typeDetect(value)}] instead.`; case 'object.parse': - return `could not parse object value from [${value}]`; + return `could not parse object value from json input`; case 'object.allowUnknown': return `definition for this key is missing`; case 'object.child': diff --git a/packages/kbn-config-schema/src/types/one_of_type.test.ts b/packages/kbn-config-schema/src/types/one_of_type.test.ts index c9da1a6cd8494b..deb87a485cdfe5 100644 --- a/packages/kbn-config-schema/src/types/one_of_type.test.ts +++ b/packages/kbn-config-schema/src/types/one_of_type.test.ts @@ -75,13 +75,20 @@ test('handles object', () => { test('handles object with wrong type', () => { const type = schema.oneOf([schema.object({ age: schema.number() })]); - expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0.age]: expected value of type [number] but got [string]" +`); }); test('includes namespace in failure', () => { const type = schema.oneOf([schema.object({ age: schema.number() })]); - expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace')) + .toThrowErrorMatchingInlineSnapshot(` +"[foo-namespace]: types that failed validation: +- [foo-namespace.0.age]: expected value of type [number] but got [string]" +`); }); test('handles multiple objects with same key', () => { @@ -110,20 +117,33 @@ test('handles maybe', () => { test('fails if not matching type', () => { const type = schema.oneOf([schema.string()]); - expect(() => type.validate(false)).toThrowErrorMatchingSnapshot(); - expect(() => type.validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [boolean]" +`); + expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [number]" +`); }); test('fails if not matching multiple types', () => { const type = schema.oneOf([schema.string(), schema.number()]); - expect(() => type.validate(false)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value of type [string] but got [boolean] +- [1]: expected value of type [number] but got [boolean]" +`); }); test('fails if not matching literal', () => { const type = schema.oneOf([schema.literal('foo')]); - expect(() => type.validate('bar')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate('bar')).toThrowErrorMatchingInlineSnapshot(` +"types that failed validation: +- [0]: expected value to equal [foo]" +`); }); test('fails if nested union type fail', () => { @@ -138,7 +158,7 @@ test('fails if nested union type fail', () => { - [0]: expected value of type [boolean] but got [string] - [1]: types that failed validation: - [0]: types that failed validation: - - [0]: could not parse object value from [aaa] + - [0]: could not parse object value from json input - [1]: expected value of type [number] but got [string]" `); }); diff --git a/packages/kbn-config-schema/src/types/record_of_type.test.ts b/packages/kbn-config-schema/src/types/record_of_type.test.ts index f3ab1925597b54..ef15e7b0f6ad6b 100644 --- a/packages/kbn-config-schema/src/types/record_of_type.test.ts +++ b/packages/kbn-config-schema/src/types/record_of_type.test.ts @@ -73,8 +73,8 @@ test('fails when not receiving expected key type', () => { expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot(` "[key(\\"name\\")]: types that failed validation: -- [0]: expected value to equal [nickName] but got [name] -- [1]: expected value to equal [lastName] but got [name]" +- [0]: expected value to equal [nickName] +- [1]: expected value to equal [lastName]" `); }); @@ -88,8 +88,8 @@ test('fails after parsing when not receiving expected key type', () => { expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot(` "[key(\\"name\\")]: types that failed validation: -- [0]: expected value to equal [nickName] but got [name] -- [1]: expected value to equal [lastName] but got [name]" +- [0]: expected value to equal [nickName] +- [1]: expected value to equal [lastName]" `); }); @@ -118,7 +118,7 @@ test('includes namespace in failure when wrong key type', () => { }; expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( - `"[foo-namespace.key(\\"name\\")]: value is [name] but it must have a minimum length of [10]."` + `"[foo-namespace.key(\\"name\\")]: value has length [4] but it must have a minimum length of [10]."` ); }); @@ -169,7 +169,7 @@ test('error preserves full path', () => { expect(() => type.validate({ grandParentKey: { parentKey: { a: 'some-value' } } }) ).toThrowErrorMatchingInlineSnapshot( - `"[grandParentKey.parentKey.key(\\"a\\")]: value is [a] but it must have a minimum length of [2]."` + `"[grandParentKey.parentKey.key(\\"a\\")]: value has length [1] but it must have a minimum length of [2]."` ); expect(() => diff --git a/packages/kbn-config-schema/src/types/record_type.ts b/packages/kbn-config-schema/src/types/record_type.ts index b795c83acdadbf..c6d4b4d71b4f1d 100644 --- a/packages/kbn-config-schema/src/types/record_type.ts +++ b/packages/kbn-config-schema/src/types/record_type.ts @@ -41,7 +41,7 @@ export class RecordOfType extends Type> { case 'record.base': return `expected value of type [object] but got [${typeDetect(value)}]`; case 'record.parse': - return `could not parse record value from [${value}]`; + return `could not parse record value from json input`; case 'record.key': case 'record.value': const childPathWithIndex = path.slice(); diff --git a/packages/kbn-config-schema/src/types/stream_type.test.ts b/packages/kbn-config-schema/src/types/stream_type.test.ts index 011fa6373df335..2e6f31ad09b34f 100644 --- a/packages/kbn-config-schema/src/types/stream_type.test.ts +++ b/packages/kbn-config-schema/src/types/stream_type.test.ts @@ -41,13 +41,17 @@ test('Passthrough is valid', () => { }); test('is required by default', () => { - expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Buffer] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.stream().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [Stream] but got [undefined]"` + ); }); describe('#defaultValue', () => { @@ -63,9 +67,15 @@ describe('#defaultValue', () => { }); test('returns error when not a stream', () => { - expect(() => schema.stream().validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => schema.stream().validate(123)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Stream] but got [number]"` + ); - expect(() => schema.stream().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.stream().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Stream] but got [Array]"` + ); - expect(() => schema.stream().validate('abc')).toThrowErrorMatchingSnapshot(); + expect(() => schema.stream().validate('abc')).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [Stream] but got [string]"` + ); }); diff --git a/packages/kbn-config-schema/src/types/string_type.test.ts b/packages/kbn-config-schema/src/types/string_type.test.ts index d599ea65c5ae22..c1d853fe82b82e 100644 --- a/packages/kbn-config-schema/src/types/string_type.test.ts +++ b/packages/kbn-config-schema/src/types/string_type.test.ts @@ -28,13 +28,17 @@ test('allows empty strings', () => { }); test('is required by default', () => { - expect(() => schema.string().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.string().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [undefined]"` + ); }); test('includes namespace in failure', () => { expect(() => schema.string().validate(undefined, {}, 'foo-namespace') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [string] but got [undefined]"` + ); }); describe('#minLength', () => { @@ -43,11 +47,17 @@ describe('#minLength', () => { }); test('returns error when shorter string', () => { - expect(() => schema.string({ minLength: 4 }).validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => + schema.string({ minLength: 4 }).validate('foo') + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [3] but it must have a minimum length of [4]."` + ); }); test('returns error when empty string', () => { - expect(() => schema.string({ minLength: 2 }).validate('')).toThrowErrorMatchingSnapshot(); + expect(() => schema.string({ minLength: 2 }).validate('')).toThrowErrorMatchingInlineSnapshot( + `"value has length [0] but it must have a minimum length of [2]."` + ); }); }); @@ -57,7 +67,11 @@ describe('#maxLength', () => { }); test('returns error when longer string', () => { - expect(() => schema.string({ maxLength: 2 }).validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => + schema.string({ maxLength: 2 }).validate('foo') + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [3] but it must have a maximum length of [2]."` + ); }); }); @@ -84,23 +98,37 @@ describe('#hostname', () => { test('returns error when value is not a valid hostname', () => { const hostNameSchema = schema.string({ hostname: true }); - expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingSnapshot(); - expect(() => hostNameSchema.validate('localhost:5601')).toThrowErrorMatchingSnapshot(); - expect(() => hostNameSchema.validate('-')).toThrowErrorMatchingSnapshot(); - expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingSnapshot(); + expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid hostname (see RFC 1123)."` + ); + expect(() => hostNameSchema.validate('localhost:5601')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid hostname (see RFC 1123)."` + ); + expect(() => hostNameSchema.validate('-')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid hostname (see RFC 1123)."` + ); + expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid hostname (see RFC 1123)."` + ); const tooLongHostName = 'a'.repeat(256); - expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingSnapshot(); + expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid hostname (see RFC 1123)."` + ); }); test('returns error when empty string', () => { - expect(() => schema.string({ hostname: true }).validate('')).toThrowErrorMatchingSnapshot(); + expect(() => schema.string({ hostname: true }).validate('')).toThrowErrorMatchingInlineSnapshot( + `"any.empty"` + ); }); test('supports string validation rules', () => { expect(() => schema.string({ hostname: true, maxLength: 3 }).validate('www.example.com') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot( + `"value has length [15] but it must have a maximum length of [3]."` + ); }); }); @@ -146,20 +174,30 @@ describe('#validate', () => { test('throws when returns string', () => { const validate = () => 'validator failure'; - expect(() => schema.string({ validate }).validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => schema.string({ validate }).validate('foo')).toThrowErrorMatchingInlineSnapshot( + `"validator failure"` + ); }); test('throw when empty string', () => { const validate = () => 'validator failure'; - expect(() => schema.string({ validate }).validate('')).toThrowErrorMatchingSnapshot(); + expect(() => schema.string({ validate }).validate('')).toThrowErrorMatchingInlineSnapshot( + `"validator failure"` + ); }); }); test('returns error when not string', () => { - expect(() => schema.string().validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => schema.string().validate(123)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [number]"` + ); - expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [Array]"` + ); - expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingSnapshot(); + expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [RegExp]"` + ); }); diff --git a/packages/kbn-config-schema/src/types/string_type.ts b/packages/kbn-config-schema/src/types/string_type.ts index 6d5fa93c0299c6..7f49440b8d7e29 100644 --- a/packages/kbn-config-schema/src/types/string_type.ts +++ b/packages/kbn-config-schema/src/types/string_type.ts @@ -45,7 +45,7 @@ export class StringType extends Type { if (options.minLength !== undefined) { schema = schema.custom(value => { if (value.length < options.minLength!) { - return `value is [${value}] but it must have a minimum length of [${options.minLength}].`; + return `value has length [${value.length}] but it must have a minimum length of [${options.minLength}].`; } }); } @@ -53,7 +53,7 @@ export class StringType extends Type { if (options.maxLength !== undefined) { schema = schema.custom(value => { if (value.length > options.maxLength!) { - return `value is [${value}] but it must have a maximum length of [${options.maxLength}].`; + return `value has length [${value.length}] but it must have a maximum length of [${options.maxLength}].`; } }); } @@ -66,7 +66,7 @@ export class StringType extends Type { case 'any.required': return `expected value of type [string] but got [${typeDetect(value)}]`; case 'string.hostname': - return `value is [${value}] but it must be a valid hostname (see RFC 1123).`; + return `value must be a valid hostname (see RFC 1123).`; } } } diff --git a/packages/kbn-config-schema/src/types/uri_type.test.ts b/packages/kbn-config-schema/src/types/uri_type.test.ts index 1345b47a63c1ff..72e5ca6f7171e6 100644 --- a/packages/kbn-config-schema/src/types/uri_type.test.ts +++ b/packages/kbn-config-schema/src/types/uri_type.test.ts @@ -20,7 +20,9 @@ import { schema } from '..'; test('is required by default', () => { - expect(() => schema.uri().validate(undefined)).toThrowErrorMatchingSnapshot(); + expect(() => schema.uri().validate(undefined)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [undefined]."` + ); }); test('returns value for valid URI as per RFC3986', () => { @@ -54,17 +56,23 @@ test('returns value for valid URI as per RFC3986', () => { test('returns error when value is not a URI', () => { const uriSchema = schema.uri(); - expect(() => uriSchema.validate('3domain.local')).toThrowErrorMatchingSnapshot(); + expect(() => uriSchema.validate('3domain.local')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid URI (see RFC 3986)."` + ); expect(() => uriSchema.validate('http://8010:0:0:0:9:500:300C:200A') - ).toThrowErrorMatchingSnapshot(); - expect(() => uriSchema.validate('-')).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"value must be a valid URI (see RFC 3986)."`); + expect(() => uriSchema.validate('-')).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid URI (see RFC 3986)."` + ); expect(() => uriSchema.validate('https://example.com?baz[]=foo&baz[]=bar') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"value must be a valid URI (see RFC 3986)."`); const tooLongUri = `http://${'a'.repeat(256)}`; - expect(() => uriSchema.validate(tooLongUri)).toThrowErrorMatchingSnapshot(); + expect(() => uriSchema.validate(tooLongUri)).toThrowErrorMatchingInlineSnapshot( + `"value must be a valid URI (see RFC 3986)."` + ); }); describe('#scheme', () => { @@ -78,8 +86,12 @@ describe('#scheme', () => { test('returns error when shorter string', () => { const uriSchema = schema.uri({ scheme: ['http', 'https'] }); - expect(() => uriSchema.validate('ftp://elastic.co')).toThrowErrorMatchingSnapshot(); - expect(() => uriSchema.validate('file:///kibana.log')).toThrowErrorMatchingSnapshot(); + expect(() => uriSchema.validate('ftp://elastic.co')).toThrowErrorMatchingInlineSnapshot( + `"expected URI with scheme [http|https]."` + ); + expect(() => uriSchema.validate('file:///kibana.log')).toThrowErrorMatchingInlineSnapshot( + `"expected URI with scheme [http|https]."` + ); }); }); @@ -131,14 +143,20 @@ describe('#validate', () => { expect(() => schema.uri({ validate }).validate('http://kibana.local') - ).toThrowErrorMatchingSnapshot(); + ).toThrowErrorMatchingInlineSnapshot(`"validator failure"`); }); }); test('returns error when not string', () => { - expect(() => schema.uri().validate(123)).toThrowErrorMatchingSnapshot(); + expect(() => schema.uri().validate(123)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [number]."` + ); - expect(() => schema.uri().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + expect(() => schema.uri().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [Array]."` + ); - expect(() => schema.uri().validate(/abc/)).toThrowErrorMatchingSnapshot(); + expect(() => schema.uri().validate(/abc/)).toThrowErrorMatchingInlineSnapshot( + `"expected value of type [string] but got [RegExp]."` + ); }); diff --git a/packages/kbn-config-schema/src/types/uri_type.ts b/packages/kbn-config-schema/src/types/uri_type.ts index df1ce9e869d3b4..f365ed35e3579a 100644 --- a/packages/kbn-config-schema/src/types/uri_type.ts +++ b/packages/kbn-config-schema/src/types/uri_type.ts @@ -36,9 +36,9 @@ export class URIType extends Type { case 'string.base': return `expected value of type [string] but got [${typeDetect(value)}].`; case 'string.uriCustomScheme': - return `expected URI with scheme [${scheme}] but got [${value}].`; + return `expected URI with scheme [${scheme}].`; case 'string.uri': - return `value is [${value}] but it must be a valid URI (see RFC 3986).`; + return `value must be a valid URI (see RFC 3986).`; } } } diff --git a/packages/kbn-storybook/storybook_config/webpack.config.js b/packages/kbn-storybook/storybook_config/webpack.config.js index 72ff9162ffe6c9..1531c1d22b01bc 100644 --- a/packages/kbn-storybook/storybook_config/webpack.config.js +++ b/packages/kbn-storybook/storybook_config/webpack.config.js @@ -19,6 +19,7 @@ const { resolve } = require('path'); const webpack = require('webpack'); +const { stringifyRequest } = require('loader-utils'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const { REPO_ROOT, DLL_DIST_DIR } = require('../lib/constants'); // eslint-disable-next-line import/no-unresolved @@ -72,6 +73,38 @@ module.exports = async ({ config }) => { ], }); + // Enable SASS + config.module.rules.push({ + test: /\.scss$/, + exclude: /\.module.(s(a|c)ss)$/, + use: [ + { loader: 'style-loader' }, + { loader: 'css-loader', options: { importLoaders: 2 } }, + { + loader: 'postcss-loader', + options: { + config: { + path: resolve(REPO_ROOT, 'src/optimize/'), + }, + }, + }, + { + loader: 'sass-loader', + options: { + prependData(loaderContext) { + return `@import ${stringifyRequest( + loaderContext, + resolve(REPO_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss') + )};\n`; + }, + sassOptions: { + includePaths: [resolve(REPO_ROOT, 'node_modules')], + }, + }, + }, + ], + }); + // Reference the built DLL file of static(ish) dependencies, which are removed // during kbn:bootstrap and rebuilt if missing. config.plugins.push( @@ -96,7 +129,7 @@ module.exports = async ({ config }) => { ); // Tell Webpack about the ts/x extensions - config.resolve.extensions.push('.ts', '.tsx'); + config.resolve.extensions.push('.ts', '.tsx', '.scss'); // Load custom Webpack config specified by a plugin. if (currentConfig.webpackHook) { diff --git a/src/cli_plugin/install/cleanup.js b/src/cli_plugin/install/cleanup.js index fa4bdcf4f69669..eaa25962ef0e44 100644 --- a/src/cli_plugin/install/cleanup.js +++ b/src/cli_plugin/install/cleanup.js @@ -27,7 +27,7 @@ export function cleanPrevious(settings, logger) { logger.log('Found previous install attempt. Deleting...'); try { - del.sync(settings.workingPath); + del.sync(settings.workingPath, { force: true }); } catch (e) { reject(e); } diff --git a/src/cli_plugin/install/install.js b/src/cli_plugin/install/install.js index 5a341e67dc128a..92be2ac2503202 100644 --- a/src/cli_plugin/install/install.js +++ b/src/cli_plugin/install/install.js @@ -46,7 +46,7 @@ export default async function install(settings, logger) { await extract(settings, logger); - del.sync(settings.tempArchiveFile); + del.sync(settings.tempArchiveFile, { force: true }); existingInstall(settings, logger); diff --git a/src/cli_plugin/remove/remove.js b/src/cli_plugin/remove/remove.js index 8432d0f44836ba..353e592390ff40 100644 --- a/src/cli_plugin/remove/remove.js +++ b/src/cli_plugin/remove/remove.js @@ -37,7 +37,7 @@ export default function remove(settings, logger) { } logger.log(`Removing ${settings.plugin}...`); - del.sync(settings.pluginPath); + del.sync(settings.pluginPath, { force: true }); logger.log('Plugin removal complete'); } catch (err) { logger.error(`Unable to remove plugin because of error: "${err.message}"`); diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index 05d718e1073dfa..d602422c146348 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -37,8 +37,7 @@ export interface CapabilitiesStart { */ export class CapabilitiesService { public async start({ appIds, http }: StartDeps): Promise { - const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : ''; - const capabilities = await http.post(`/api/core/capabilities${route}`, { + const capabilities = await http.post('/api/core/capabilities', { body: JSON.stringify({ applications: appIds }), }); diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts index aace0b9debf9c0..7d2e7391aa8d4b 100644 --- a/src/core/server/capabilities/capabilities_service.test.ts +++ b/src/core/server/capabilities/capabilities_service.test.ts @@ -41,8 +41,8 @@ describe('CapabilitiesService', () => { }); it('registers the capabilities routes', async () => { - expect(http.createRouter).toHaveBeenCalledWith('/api/core/capabilities'); - expect(router.post).toHaveBeenCalledTimes(2); + expect(http.createRouter).toHaveBeenCalledWith(''); + expect(router.post).toHaveBeenCalledTimes(1); expect(router.post).toHaveBeenCalledWith(expect.any(Object), expect.any(Function)); }); diff --git a/src/core/server/capabilities/routes/index.ts b/src/core/server/capabilities/routes/index.ts index ccaa4621d70035..74c485986a77b2 100644 --- a/src/core/server/capabilities/routes/index.ts +++ b/src/core/server/capabilities/routes/index.ts @@ -22,6 +22,6 @@ import { InternalHttpServiceSetup } from '../../http'; import { registerCapabilitiesRoutes } from './resolve_capabilities'; export function registerRoutes(http: InternalHttpServiceSetup, resolver: CapabilitiesResolver) { - const router = http.createRouter('/api/core/capabilities'); + const router = http.createRouter(''); registerCapabilitiesRoutes(router, resolver); } diff --git a/src/core/server/capabilities/routes/resolve_capabilities.ts b/src/core/server/capabilities/routes/resolve_capabilities.ts index 5e1d49b4b1b7e8..3fb1bb3d13d0bf 100644 --- a/src/core/server/capabilities/routes/resolve_capabilities.ts +++ b/src/core/server/capabilities/routes/resolve_capabilities.ts @@ -22,30 +22,24 @@ import { IRouter } from '../../http'; import { CapabilitiesResolver } from '../resolve_capabilities'; export function registerCapabilitiesRoutes(router: IRouter, resolver: CapabilitiesResolver) { - // Capabilities are fetched on both authenticated and anonymous routes. - // However when `authRequired` is false, authentication is not performed - // and only default capabilities are returned (all disabled), even for authenticated users. - // So we need two endpoints to handle both scenarios. - [true, false].forEach(authRequired => { - router.post( - { - path: authRequired ? '' : '/defaults', - options: { - authRequired, - }, - validate: { - body: schema.object({ - applications: schema.arrayOf(schema.string()), - }), - }, + router.post( + { + path: '/api/core/capabilities', + options: { + authRequired: 'optional', }, - async (ctx, req, res) => { - const { applications } = req.body; - const capabilities = await resolver(req, applications); - return res.ok({ - body: capabilities, - }); - } - ); - }); + validate: { + body: schema.object({ + applications: schema.arrayOf(schema.string()), + }), + }, + }, + async (ctx, req, res) => { + const { applications } = req.body; + const capabilities = await resolver(req, applications); + return res.ok({ + body: capabilities, + }); + } + ); } diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 28933a035c8709..07c153a7a8a200 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -87,7 +87,7 @@ exports[`throws if basepath is missing prepended slash 1`] = `"[basePath]: must exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = `"cannot use [rewriteBasePath] when [basePath] is not specified"`; -exports[`throws if invalid hostname 1`] = `"[host]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`; +exports[`throws if invalid hostname 1`] = `"[host]: value must be a valid hostname (see RFC 1123)."`; exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`; @@ -100,7 +100,7 @@ Array [ ] `; -exports[`with compression throws if invalid referrer whitelist 1`] = `"[compression.referrerWhitelist.0]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`; +exports[`with compression throws if invalid referrer whitelist 1`] = `"[compression.referrerWhitelist.0]: value must be a valid hostname (see RFC 1123)."`; exports[`with compression throws if invalid referrer whitelist 2`] = `"[compression.referrerWhitelist]: array size is [0], but cannot be smaller than [1]"`; diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 741c723ca93652..bbef0a105c0896 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -36,6 +36,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; interface RequestFixtureOptions

{ + auth?: { isAuthenticated: boolean }; headers?: Record; params?: Record; body?: Record; @@ -65,11 +66,13 @@ function createKibanaRequestMock

({ routeAuthRequired, validation = {}, kibanaRouteState = { xsrfRequired: true }, + auth = { isAuthenticated: true }, }: RequestFixtureOptions = {}) { const queryString = stringify(query, { sort: false }); return KibanaRequest.from( createRawRequestMock({ + auth, headers, params, query, @@ -113,6 +116,9 @@ function createRawRequestMock(customization: DeepPartial = {}) { {}, { app: { xsrfRequired: true } as any, + auth: { + isAuthenticated: true, + }, headers: {}, path: '/', route: { settings: {} }, diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index cffdffab0d0cf7..f898ed0ea1a991 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -26,8 +26,7 @@ import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; - -import { IRouter, KibanaRouteState, isSafeMethod } from './router'; +import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router'; import { SessionStorageCookieOptions, createCookieSessionStorageFactory, @@ -148,7 +147,7 @@ export class HttpServer { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired = true, tags, body = {} } = route.options; + const { authRequired, tags, body = {} } = route.options; const { accepts: allow, maxBytes, output, parse } = body; const kibanaRouteState: KibanaRouteState = { @@ -160,8 +159,7 @@ export class HttpServer { method: route.method, path: route.path, options: { - // Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }` - auth: authRequired === true ? undefined : false, + auth: this.getAuthOption(authRequired), app: kibanaRouteState, tags: tags ? Array.from(tags) : undefined, // TODO: This 'validate' section can be removed once the legacy platform is completely removed. @@ -196,6 +194,22 @@ export class HttpServer { this.server = undefined; } + private getAuthOption( + authRequired: RouteConfigOptions['authRequired'] = true + ): undefined | false | { mode: 'required' | 'optional' } { + if (this.authRegistered === false) return undefined; + + if (authRequired === true) { + return { mode: 'required' }; + } + if (authRequired === 'optional') { + return { mode: 'optional' }; + } + if (authRequired === false) { + return false; + } + } + private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) { if (config.basePath === undefined || !config.rewriteBasePath) { return; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 30032ff5da7968..442bc93190d86d 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -115,6 +115,8 @@ const createOnPostAuthToolkitMock = (): jest.Mocked => ({ const createAuthToolkitMock = (): jest.Mocked => ({ authenticated: jest.fn(), + notHandled: jest.fn(), + redirected: jest.fn(), }); const createOnPreResponseToolkitMock = (): jest.Mocked => ({ diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 8f4c02680f8a30..a75eb04fa01204 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -67,9 +67,12 @@ export { AuthenticationHandler, AuthHeaders, AuthResultParams, + AuthRedirected, + AuthRedirectedParams, AuthToolkit, AuthResult, Authenticated, + AuthNotHandled, AuthResultType, } from './lifecycle/auth'; export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth'; diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 425d8cac1893ea..7b1630a7de0be0 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -50,7 +50,7 @@ describe('http service', () => { await root.shutdown(); }); describe('#isAuthenticated()', () => { - it('returns true if has been authorized', async () => { + it('returns true if has been authenticated', async () => { const { http } = await root.setup(); const { registerAuth, createRouter, auth } = http; @@ -65,11 +65,11 @@ describe('http service', () => { await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true }); }); - it('returns false if has not been authorized', async () => { + it('returns false if has not been authenticated', async () => { const { http } = await root.setup(); const { registerAuth, createRouter, auth } = http; - await registerAuth((req, res, toolkit) => toolkit.authenticated()); + registerAuth((req, res, toolkit) => toolkit.authenticated()); const router = createRouter(''); router.get( @@ -81,7 +81,7 @@ describe('http service', () => { await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false }); }); - it('returns false if no authorization mechanism has been registered', async () => { + it('returns false if no authentication mechanism has been registered', async () => { const { http } = await root.setup(); const { createRouter, auth } = http; @@ -94,6 +94,37 @@ describe('http service', () => { await root.start(); await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false }); }); + + it('returns true if authenticated on a route with "optional" auth', async () => { + const { http } = await root.setup(); + const { createRouter, auth, registerAuth } = http; + + registerAuth((req, res, toolkit) => toolkit.authenticated()); + const router = createRouter(''); + router.get( + { path: '/is-auth', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } }) + ); + + await root.start(); + await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true }); + }); + + it('returns false if not authenticated on a route with "optional" auth', async () => { + const { http } = await root.setup(); + const { createRouter, auth, registerAuth } = http; + + registerAuth((req, res, toolkit) => toolkit.notHandled()); + + const router = createRouter(''); + router.get( + { path: '/is-auth', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } }) + ); + + await root.start(); + await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false }); + }); }); describe('#get()', () => { it('returns authenticated status and allow associate auth state with request', async () => { diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 6dc7ece1359df7..0f0d54e88daca3 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -57,7 +57,7 @@ interface StorageData { } describe('OnPreAuth', () => { - it('supports registering request inceptors', async () => { + it('supports registering a request interceptor', async () => { const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -415,6 +415,23 @@ describe('Auth', () => { .expect(200, { content: 'ok' }); }); + it('blocks access to a resource if credentials are not provided', async () => { + const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => + res.ok({ body: { content: 'ok' } }) + ); + registerAuth((req, res, t) => t.notHandled()); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body.message).toBe('Unauthorized'); + }); + it('enables auth for a route by default if registerAuth has been called', async () => { const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -492,11 +509,9 @@ describe('Auth', () => { router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); const redirectTo = '/redirect-url'; - registerAuth((req, res) => - res.redirected({ - headers: { - location: redirectTo, - }, + registerAuth((req, res, t) => + t.redirected({ + location: redirectTo, }) ); await server.start(); @@ -507,6 +522,19 @@ describe('Auth', () => { expect(response.header.location).toBe(redirectTo); }); + it('throws if redirection url is not provided', async () => { + const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + registerAuth((req, res, t) => t.redirected({} as any)); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(500); + }); + it(`doesn't expose internal error details`, async () => { const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -865,7 +893,7 @@ describe('Auth', () => { ] `); }); - // eslint-disable-next-line + it(`doesn't share request object between interceptors`, async () => { const { registerOnPostAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index bc1bbc881315ab..85270174fbc048 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -45,6 +45,89 @@ afterEach(async () => { const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('KibanaRequest', () => { + describe('auth', () => { + describe('isAuthenticated', () => { + it('returns false if no auth interceptor was registered', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + isAuthenticated: false, + }); + }); + it('returns false if not authenticated on a route with authRequired: "optional"', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + registerAuth((req, res, toolkit) => toolkit.notHandled()); + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + isAuthenticated: false, + }); + }); + it('returns false if redirected on a route with authRequired: "optional"', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + registerAuth((req, res, toolkit) => toolkit.redirected({ location: '/any' })); + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + isAuthenticated: false, + }); + }); + it('returns true if authenticated on a route with authRequired: "optional"', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + registerAuth((req, res, toolkit) => toolkit.authenticated()); + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + isAuthenticated: true, + }); + }); + it('returns true if authenticated', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + registerAuth((req, res, toolkit) => toolkit.authenticated()); + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + isAuthenticated: true, + }); + }); + }); + }); describe('events', () => { describe('aborted$', () => { it('emits once and completes when request aborted', async done => { diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index a1523781010d47..ee5b0c50acafb1 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -46,6 +46,286 @@ afterEach(async () => { await server.stop(); }); +describe('Options', () => { + describe('authRequired', () => { + describe('optional', () => { + it('User has access to a route if auth mechanism not registered', async () => { + const { server: innerServer, createRouter, auth } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: false, + requestIsAuthenticated: false, + }); + }); + + it('Authenticated user has access to a route', async () => { + const { server: innerServer, createRouter, registerAuth, auth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => { + return toolkit.authenticated(); + }); + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: true, + requestIsAuthenticated: true, + }); + }); + + it('User with no credentials can access a route', async () => { + const { server: innerServer, createRouter, registerAuth, auth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => toolkit.notHandled()); + + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: false, + requestIsAuthenticated: false, + }); + }); + + it('User with invalid credentials cannot access a route', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => res.unauthorized()); + + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => res.ok({ body: 'ok' }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(401); + }); + + it('does not redirect user and allows access to a resource', async () => { + const { server: innerServer, createRouter, registerAuth, auth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => + toolkit.redirected({ + location: '/redirect-to', + }) + ); + + router.get( + { path: '/', validate: false, options: { authRequired: 'optional' } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: false, + requestIsAuthenticated: false, + }); + }); + }); + + describe('true', () => { + it('User has access to a route if auth interceptor is not registered', async () => { + const { server: innerServer, createRouter, auth } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: false, + requestIsAuthenticated: false, + }); + }); + + it('Authenticated user has access to a route', async () => { + const { server: innerServer, createRouter, registerAuth, auth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => { + return toolkit.authenticated(); + }); + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: true, + requestIsAuthenticated: true, + }); + }); + + it('User with no credentials cannot access a route', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => toolkit.notHandled()); + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => res.ok({ body: 'ok' }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(401); + }); + + it('User with invalid credentials cannot access a route', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + + registerAuth((req, res, toolkit) => res.unauthorized()); + + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => res.ok({ body: 'ok' }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(401); + }); + + it('allows redirecting an user', async () => { + const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps); + const router = createRouter('/'); + const redirectUrl = '/redirect-to'; + + registerAuth((req, res, toolkit) => + toolkit.redirected({ + location: redirectUrl, + }) + ); + + router.get( + { path: '/', validate: false, options: { authRequired: true } }, + (context, req, res) => res.ok({ body: 'ok' }) + ); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(302); + + expect(result.header.location).toBe(redirectUrl); + }); + }); + + describe('false', () => { + it('does not try to authenticate a user', async () => { + const { server: innerServer, createRouter, registerAuth, auth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + const authHook = jest.fn(); + registerAuth(authHook); + router.get( + { path: '/', validate: false, options: { authRequired: false } }, + (context, req, res) => + res.ok({ + body: { + httpAuthIsAuthenticated: auth.isAuthenticated(req), + requestIsAuthenticated: req.auth.isAuthenticated, + }, + }) + ); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200, { + httpAuthIsAuthenticated: false, + requestIsAuthenticated: false, + }); + + expect(authHook).toHaveBeenCalledTimes(0); + }); + }); + }); +}); + describe('Handler', () => { it("Doesn't expose error details if handler throws", async () => { const { server: innerServer, createRouter } = await server.setup(setupDeps); diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts index 036ab0211c2ff5..2eaf7e0f6fbfed 100644 --- a/src/core/server/http/lifecycle/auth.ts +++ b/src/core/server/http/lifecycle/auth.ts @@ -25,11 +25,14 @@ import { lifecycleResponseFactory, LifecycleResponseFactory, isKibanaResponse, + ResponseHeaders, } from '../router'; /** @public */ export enum AuthResultType { authenticated = 'authenticated', + notHandled = 'notHandled', + redirected = 'redirected', } /** @public */ @@ -38,10 +41,20 @@ export interface Authenticated extends AuthResultParams { } /** @public */ -export type AuthResult = Authenticated; +export interface AuthNotHandled { + type: AuthResultType.notHandled; +} + +/** @public */ +export interface AuthRedirected extends AuthRedirectedParams { + type: AuthResultType.redirected; +} + +/** @public */ +export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected; const authResult = { - authenticated(data: Partial = {}): AuthResult { + authenticated(data: AuthResultParams = {}): AuthResult { return { type: AuthResultType.authenticated, state: data.state, @@ -49,8 +62,25 @@ const authResult = { responseHeaders: data.responseHeaders, }; }, + notHandled(): AuthResult { + return { + type: AuthResultType.notHandled, + }; + }, + redirected(headers: { location: string } & ResponseHeaders): AuthResult { + return { + type: AuthResultType.redirected, + headers, + }; + }, isAuthenticated(result: AuthResult): result is Authenticated { - return result && result.type === AuthResultType.authenticated; + return result?.type === AuthResultType.authenticated; + }, + isNotHandled(result: AuthResult): result is AuthNotHandled { + return result?.type === AuthResultType.notHandled; + }, + isRedirected(result: AuthResult): result is AuthRedirected { + return result?.type === AuthResultType.redirected; }, }; @@ -62,7 +92,7 @@ const authResult = { export type AuthHeaders = Record; /** - * Result of an incoming request authentication. + * Result of successful authentication. * @public */ export interface AuthResultParams { @@ -82,6 +112,18 @@ export interface AuthResultParams { responseHeaders?: AuthHeaders; } +/** + * Result of auth redirection. + * @public + */ +export interface AuthRedirectedParams { + /** + * Headers to attach for auth redirect. + * Must include "location" header + */ + headers: { location: string } & ResponseHeaders; +} + /** * @public * A tool set defining an outcome of Auth interceptor for incoming request. @@ -89,10 +131,23 @@ export interface AuthResultParams { export interface AuthToolkit { /** Authentication is successful with given credentials, allow request to pass through */ authenticated: (data?: AuthResultParams) => AuthResult; + /** + * User has no credentials. + * Allows user to access a resource when authRequired: 'optional' + * Rejects a request when authRequired: true + * */ + notHandled: () => AuthResult; + /** + * Redirects user to another location to complete authentication when authRequired: true + * Allows user to access a resource without redirection when authRequired: 'optional' + * */ + redirected: (headers: { location: string } & ResponseHeaders) => AuthResult; } const toolkit: AuthToolkit = { authenticated: authResult.authenticated, + notHandled: authResult.notHandled, + redirected: authResult.redirected, }; /** @@ -109,30 +164,51 @@ export type AuthenticationHandler = ( export function adoptToHapiAuthFormat( fn: AuthenticationHandler, log: Logger, - onSuccess: (req: Request, data: AuthResultParams) => void = () => undefined + onAuth: (request: Request, data: AuthResultParams) => void = () => undefined ) { return async function interceptAuth( request: Request, responseToolkit: ResponseToolkit ): Promise { const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); + const kibanaRequest = KibanaRequest.from(request, undefined, false); + try { - const result = await fn( - KibanaRequest.from(request, undefined, false), - lifecycleResponseFactory, - toolkit - ); + const result = await fn(kibanaRequest, lifecycleResponseFactory, toolkit); + if (isKibanaResponse(result)) { return hapiResponseAdapter.handle(result); } + if (authResult.isAuthenticated(result)) { - onSuccess(request, { + onAuth(request, { state: result.state, requestHeaders: result.requestHeaders, responseHeaders: result.responseHeaders, }); return responseToolkit.authenticated({ credentials: result.state || {} }); } + + if (authResult.isRedirected(result)) { + // we cannot redirect a user when resources with optional auth requested + if (kibanaRequest.route.options.authRequired === 'optional') { + return responseToolkit.continue; + } + + return hapiResponseAdapter.handle( + lifecycleResponseFactory.redirected({ + // hapi doesn't accept string[] as a valid header + headers: result.headers as any, + }) + ); + } + + if (authResult.isNotHandled(result)) { + if (kibanaRequest.route.options.authRequired === 'optional') { + return responseToolkit.continue; + } + return hapiResponseAdapter.handle(lifecycleResponseFactory.unauthorized()); + } throw new Error( `Unexpected result from Authenticate. Expected AuthResult or KibanaResponse, but given: ${result}.` ); diff --git a/src/core/server/http/router/request.test.ts b/src/core/server/http/router/request.test.ts index 032027c2344858..fb999dc60e39c5 100644 --- a/src/core/server/http/router/request.test.ts +++ b/src/core/server/http/router/request.test.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { RouteOptions } from 'hapi'; import { KibanaRequest } from './request'; import { httpServerMock } from '../http_server.mocks'; import { schema } from '@kbn/config-schema'; @@ -117,6 +118,106 @@ describe('KibanaRequest', () => { }); }); + describe('route.options.authRequired property', () => { + it('handles required auth: undefined', () => { + const auth: RouteOptions['auth'] = undefined; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + const kibanaRequest = KibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(true); + }); + it('handles required auth: false', () => { + const auth: RouteOptions['auth'] = false; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + const kibanaRequest = KibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(false); + }); + it('handles required auth: { mode: "required" }', () => { + const auth: RouteOptions['auth'] = { mode: 'required' }; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + const kibanaRequest = KibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe(true); + }); + + it('handles required auth: { mode: "optional" }', () => { + const auth: RouteOptions['auth'] = { mode: 'optional' }; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + const kibanaRequest = KibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe('optional'); + }); + + it('handles required auth: { mode: "try" } as "optional"', () => { + const auth: RouteOptions['auth'] = { mode: 'try' }; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + const kibanaRequest = KibanaRequest.from(request); + + expect(kibanaRequest.route.options.authRequired).toBe('optional'); + }); + + it('throws on auth: strategy name', () => { + const auth: RouteOptions['auth'] = 'session'; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + + expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot( + `"unexpected authentication options: \\"session\\" for route: /"` + ); + }); + + it('throws on auth: { mode: unexpected mode }', () => { + const auth: RouteOptions['auth'] = { mode: undefined }; + const request = httpServerMock.createRawRequest({ + route: { + settings: { + auth, + }, + }, + }); + + expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot( + `"unexpected authentication options: {} for route: /"` + ); + }); + }); + describe('RouteSchema type inferring', () => { it('should work with config-schema', () => { const body = Buffer.from('body!'); diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index bb2db6367f701f..f266677c1a1727 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -143,6 +143,10 @@ export class KibanaRequest< public readonly socket: IKibanaSocket; /** Request events {@link KibanaRequestEvents} */ public readonly events: KibanaRequestEvents; + public readonly auth: { + /* true if the request has been successfully authenticated, otherwise false. */ + isAuthenticated: boolean; + }; /** @internal */ protected readonly [requestSymbol]: Request; @@ -172,6 +176,11 @@ export class KibanaRequest< this.route = deepFreeze(this.getRouteInfo(request)); this.socket = new KibanaSocket(request.raw.req.socket); this.events = this.getEvents(request); + + this.auth = { + // missing in fakeRequests, so we cast to false + isAuthenticated: Boolean(request.auth?.isAuthenticated), + }; } private getEvents(request: Request): KibanaRequestEvents { @@ -189,7 +198,7 @@ export class KibanaRequest< const { parse, maxBytes, allow, output } = request.route.settings.payload || {}; const options = ({ - authRequired: request.route.settings.auth !== false, + authRequired: this.getAuthRequired(request), // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], @@ -209,6 +218,31 @@ export class KibanaRequest< options, }; } + + private getAuthRequired(request: Request): boolean | 'optional' { + const authOptions = request.route.settings.auth; + if (typeof authOptions === 'object') { + // 'try' is used in the legacy platform + if (authOptions.mode === 'optional' || authOptions.mode === 'try') { + return 'optional'; + } + if (authOptions.mode === 'required') { + return true; + } + } + + // legacy platform routes + if (authOptions === undefined) { + return true; + } + + if (authOptions === false) return false; + throw new Error( + `unexpected authentication options: ${JSON.stringify(authOptions)} for route: ${ + this.url.href + }` + ); + } } /** diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index d1458ef4ad0632..bb0a8616e72222 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -116,13 +116,15 @@ export interface RouteConfigOptionsBody { */ export interface RouteConfigOptions { /** - * A flag shows that authentication for a route: - * `enabled` when true - * `disabled` when false + * Defines authentication mode for a route: + * - true. A user has to have valid credentials to access a resource + * - false. A user can access a resource without any credentials. + * - 'optional'. A user can access a resource if has valid credentials or no credentials at all. + * Can be useful when we grant access to a resource but want to identify a user if possible. * - * Enabled by default. + * Defaults to `true` if an auth mechanism is registered. */ - authRequired?: boolean; + authRequired?: boolean | 'optional'; /** * Defines xsrf protection requirements for a route: diff --git a/src/core/server/http/ssl_config.test.ts b/src/core/server/http/ssl_config.test.ts index 738f86f7a69eb8..3980b9c247fa33 100644 --- a/src/core/server/http/ssl_config.test.ts +++ b/src/core/server/http/ssl_config.test.ts @@ -293,16 +293,16 @@ describe('#sslSchema', () => { expect(() => sslSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingInlineSnapshot(` "[supportedProtocols.0]: types that failed validation: -- [supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +- [supportedProtocols.0.0]: expected value to equal [TLSv1] +- [supportedProtocols.0.1]: expected value to equal [TLSv1.1] +- [supportedProtocols.0.2]: expected value to equal [TLSv1.2]" `); expect(() => sslSchema.validate(allKnownWithOneUnknownProtocols)) .toThrowErrorMatchingInlineSnapshot(` "[supportedProtocols.3]: types that failed validation: -- [supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +- [supportedProtocols.3.0]: expected value to equal [TLSv1] +- [supportedProtocols.3.1]: expected value to equal [TLSv1.1] +- [supportedProtocols.3.2]: expected value to equal [TLSv1.2]" `); }); }); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 8e481171116faf..80eabe778ece33 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -100,9 +100,12 @@ export { AuthResultParams, AuthStatus, AuthToolkit, + AuthRedirected, + AuthRedirectedParams, AuthResult, AuthResultType, Authenticated, + AuthNotHandled, BasePath, IBasePath, CustomHttpResponseOptions, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 30695df33345ad..f7afe7a6a290aa 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -419,7 +419,26 @@ export type AuthenticationHandler = (request: KibanaRequest, response: Lifecycle export type AuthHeaders = Record; // @public (undocumented) -export type AuthResult = Authenticated; +export interface AuthNotHandled { + // (undocumented) + type: AuthResultType.notHandled; +} + +// @public (undocumented) +export interface AuthRedirected extends AuthRedirectedParams { + // (undocumented) + type: AuthResultType.redirected; +} + +// @public +export interface AuthRedirectedParams { + headers: { + location: string; + } & ResponseHeaders; +} + +// @public (undocumented) +export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected; // @public export interface AuthResultParams { @@ -431,7 +450,11 @@ export interface AuthResultParams { // @public (undocumented) export enum AuthResultType { // (undocumented) - authenticated = "authenticated" + authenticated = "authenticated", + // (undocumented) + notHandled = "notHandled", + // (undocumented) + redirected = "redirected" } // @public @@ -444,6 +467,10 @@ export enum AuthStatus { // @public export interface AuthToolkit { authenticated: (data?: AuthResultParams) => AuthResult; + notHandled: () => AuthResult; + redirected: (headers: { + location: string; + } & ResponseHeaders) => AuthResult; } // @public @@ -970,6 +997,10 @@ export class KibanaRequest { // @public export interface RouteConfigOptions { - authRequired?: boolean; + authRequired?: boolean | 'optional'; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; xsrfRequired?: Method extends 'get' ? never : boolean; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 35ac4e27f9c8b3..8ed64f004c9be3 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -25,4 +25,5 @@ export const storybookAliases = { embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', + ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx index e09f26311e4e32..2278b243ecc143 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx @@ -45,7 +45,10 @@ beforeEach(() => { jest.clearAllMocks(); }); -export const waitForPromises = () => new Promise(resolve => setTimeout(resolve, 0)); +const waitForPromises = async () => + act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); /** * this works but logs ugly error messages until we're using React 16.9 diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts index cd7c8278adcc7c..5a8460fcb51bae 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts @@ -19,7 +19,8 @@ import { getIndices } from './get_indices'; import { IndexPatternCreationConfig } from './../../../../../../../management/public'; -import { LegacyApiCaller } from '../../../../../../../../../plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { LegacyApiCaller } from '../../../../../../../../../plugins/data/public/search'; export const successfulResponse = { hits: { diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts index 42d4e04184a257..9e2bfd4023bd96 100644 --- a/src/legacy/core_plugins/timelion/index.ts +++ b/src/legacy/core_plugins/timelion/index.ts @@ -62,14 +62,6 @@ const timelionPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPl }, styleSheetPaths: resolve(__dirname, 'public/index.scss'), hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars(server) { - const config = server.config(); - - return { - timelionUiEnabled: config.get('timelion.ui.enabled'), - kbnIndex: config.get('kibana.index'), - }; - }, mappings: require('./mappings.json'), uiSettingDefaults: { 'timelion:showTutorial': { diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts index 63030fcbce3873..acb95e80fe18c0 100644 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ b/src/legacy/core_plugins/timelion/public/legacy.ts @@ -18,7 +18,7 @@ */ import { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; +import { npSetup } from 'ui/new_platform'; import { plugin } from '.'; import { TimelionPluginSetupDependencies } from './plugin'; import { LegacyDependenciesPlugin } from './shim'; @@ -32,4 +32,4 @@ const setupPlugins: Readonly = { const pluginInstance = plugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core); +export const start = pluginInstance.start(); diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts index 636b8bf8e128aa..8b021cda4bfb0b 100644 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ b/src/legacy/core_plugins/timelion/public/plugin.ts @@ -16,13 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { - CoreSetup, - CoreStart, - Plugin, - PluginInitializerContext, - IUiSettingsClient, -} from 'kibana/public'; +import { CoreSetup, Plugin, PluginInitializerContext, IUiSettingsClient } from 'kibana/public'; import { getTimeChart } from './panels/timechart/timechart'; import { Panel } from './panels/panel'; import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; @@ -65,13 +59,7 @@ export class TimelionPlugin implements Plugin, void> { dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); } - public start(core: CoreStart) { - const timelionUiEnabled = core.injectedMetadata.getInjectedVar('timelionUiEnabled'); - - if (timelionUiEnabled === false) { - core.chrome.navLinks.update('timelion', { hidden: true }); - } - } + public start() {} public stop(): void {} } diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/common/es_query/filters/get_display_value.ts index 4bf7e1c9c6ba70..03167f3080419a 100644 --- a/src/plugins/data/common/es_query/filters/get_display_value.ts +++ b/src/plugins/data/common/es_query/filters/get_display_value.ts @@ -18,6 +18,7 @@ */ import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; import { IIndexPattern, IFieldType } from '../..'; import { getIndexPatternFromFilter } from './get_index_pattern_from_filter'; import { Filter } from '../filters'; @@ -27,7 +28,16 @@ function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { let format = get(indexPattern, ['fields', 'byName', key, 'format']); if (!format && (indexPattern.fields as any).getByName) { // TODO: Why is indexPatterns sometimes a map and sometimes an array? - format = ((indexPattern.fields as any).getByName(key) as IFieldType).format; + const field: IFieldType = (indexPattern.fields as any).getByName(key); + if (!field) { + throw new Error( + i18n.translate('data.filter.filterBar.fieldNotFound', { + defaultMessage: 'Field {key} not found in index pattern {indexPattern}', + values: { key, indexPattern: indexPattern.title }, + }) + ); + } + format = field.format; } return format; } diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index e02045de24e8f5..7fa6e88b427a95 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -24,4 +24,5 @@ export * from './index_patterns'; export * from './es_query'; export * from './utils'; export * from './types'; +export * from './search'; export * from './constants'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index a5f4ce2ce3c581..86cc0cca85e0b1 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -283,8 +283,39 @@ export { * Search: */ -export { IRequestTypesMap, IResponseTypesMap } from './search'; -export * from './search'; +export { + ES_SEARCH_STRATEGY, + SYNC_SEARCH_STRATEGY, + defaultSearchStrategy, + esSearchStrategyProvider, + getEsPreference, + addSearchStrategy, + hasSearchStategyForIndexPattern, + getSearchErrorType, + ISearchContext, + TSearchStrategyProvider, + ISearchStrategy, + ISearch, + ISearchOptions, + IRequestTypesMap, + IResponseTypesMap, + ISearchGeneric, + IEsSearchResponse, + IEsSearchRequest, + ISyncSearchRequest, + IKibanaSearchResponse, + IKibanaSearchRequest, + SearchRequest, + SearchResponse, + SearchError, + SearchStrategyProvider, + ISearchSource, + SearchSource, + SearchSourceFields, + EsQuerySortValue, + SortDirection, + FetchOptions, +} from './search'; /* * UI components diff --git a/src/plugins/data/public/search/es_search/es_search_strategy.ts b/src/plugins/data/public/search/es_search/es_search_strategy.ts index 5382a59123e780..a61428c998157e 100644 --- a/src/plugins/data/public/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/public/search/es_search/es_search_strategy.ts @@ -30,11 +30,10 @@ export const esSearchStrategyProvider: TSearchStrategyProvider { - if (typeof request.params.preference === 'undefined') { - const setPreference = context.core.uiSettings.get('courier:setRequestPreference'); - const customPreference = context.core.uiSettings.get('courier:customRequestPreference'); - request.params.preference = getEsPreference(setPreference, customPreference); - } + request.params = { + preference: getEsPreference(context.core.uiSettings), + ...request.params, + }; return search({ ...request, serverStrategy: ES_SEARCH_STRATEGY }, options) as Observable< IEsSearchResponse >; diff --git a/src/plugins/data/public/search/es_search/get_es_preference.test.ts b/src/plugins/data/public/search/es_search/get_es_preference.test.ts index 27e6f9b48bbdd5..8b8156b4519d64 100644 --- a/src/plugins/data/public/search/es_search/get_es_preference.test.ts +++ b/src/plugins/data/public/search/es_search/get_es_preference.test.ts @@ -18,29 +18,40 @@ */ import { getEsPreference } from './get_es_preference'; - -jest.useFakeTimers(); +import { CoreStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; describe('Get ES preference', () => { + let mockCoreStart: MockedKeys; + + beforeEach(() => { + mockCoreStart = coreMock.createStart(); + }); + test('returns the session ID if set to sessionId', () => { - const setPreference = 'sessionId'; - const customPreference = 'foobar'; - const sessionId = 'my_session_id'; - const preference = getEsPreference(setPreference, customPreference, sessionId); - expect(preference).toBe(sessionId); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'sessionId'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings, 'my_session_id'); + expect(preference).toBe('my_session_id'); }); test('returns the custom preference if set to custom', () => { - const setPreference = 'custom'; - const customPreference = 'foobar'; - const preference = getEsPreference(setPreference, customPreference); - expect(preference).toBe(customPreference); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'custom'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings); + expect(preference).toBe('foobar'); }); test('returns undefined if set to none', () => { - const setPreference = 'none'; - const customPreference = 'foobar'; - const preference = getEsPreference(setPreference, customPreference); + mockCoreStart.uiSettings.get.mockImplementation((key: string) => { + if (key === 'courier:setRequestPreference') return 'none'; + if (key === 'courier:customRequestPreference') return 'foobar'; + }); + const preference = getEsPreference(mockCoreStart.uiSettings); expect(preference).toBe(undefined); }); }); diff --git a/src/plugins/data/public/search/es_search/get_es_preference.ts b/src/plugins/data/public/search/es_search/get_es_preference.ts index 200e5bacb7f182..3f1c2b9b3b7369 100644 --- a/src/plugins/data/public/search/es_search/get_es_preference.ts +++ b/src/plugins/data/public/search/es_search/get_es_preference.ts @@ -17,13 +17,13 @@ * under the License. */ +import { IUiSettingsClient } from '../../../../../core/public'; + const defaultSessionId = `${Date.now()}`; -export function getEsPreference( - setRequestPreference: string, - customRequestPreference?: string, - sessionId: string = defaultSessionId -) { - if (setRequestPreference === 'sessionId') return `${sessionId}`; - return setRequestPreference === 'custom' ? customRequestPreference : undefined; +export function getEsPreference(uiSettings: IUiSettingsClient, sessionId = defaultSessionId) { + const setPreference = uiSettings.get('courier:setRequestPreference'); + if (setPreference === 'sessionId') return `${sessionId}`; + const customPreference = uiSettings.get('courier:customRequestPreference'); + return setPreference === 'custom' ? customPreference : undefined; } diff --git a/src/plugins/data/public/search/es_search/index.ts b/src/plugins/data/public/search/es_search/index.ts new file mode 100644 index 00000000000000..41c6ec388bfafe --- /dev/null +++ b/src/plugins/data/public/search/es_search/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export { esSearchStrategyProvider } from './es_search_strategy'; +export { getEsPreference } from './get_es_preference'; diff --git a/src/plugins/data/public/search/fetch/types.ts b/src/plugins/data/public/search/fetch/types.ts index 62eb965703c3a4..e8de0576b8a72f 100644 --- a/src/plugins/data/public/search/fetch/types.ts +++ b/src/plugins/data/public/search/fetch/types.ts @@ -17,8 +17,8 @@ * under the License. */ -import { ISearchStart } from 'src/plugins/data/public'; import { IUiSettingsClient } from '../../../../../core/public'; +import { ISearchStart } from '../types'; export interface FetchOptions { abortSignal?: AbortSignal; diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 853dbd09e1f939..2a54cfe2be7857 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -36,6 +36,7 @@ export { export { IEsSearchResponse, IEsSearchRequest, ES_SEARCH_STRATEGY } from '../../common/search'; export { ISyncSearchRequest, SYNC_SEARCH_STRATEGY } from './sync_search_strategy'; +export { esSearchStrategyProvider, getEsPreference } from './es_search'; export { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search'; diff --git a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss index 51204e2a611688..24adf0093af952 100644 --- a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss +++ b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss @@ -32,6 +32,15 @@ font-style: italic; } +.globalFilterItem-isInvalid { + text-decoration: none; + + .globalFilterLabel__value { + color: $euiColorDanger; + font-weight: $euiFontWeightBold; + } +} + .globalFilterItem-isPinned { position: relative; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx index ee6d178b25c22e..070631354d8b80 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx @@ -41,6 +41,10 @@ export function FilterLabel({ filter, valueLabel }: Props) { prefixText ); + const getValue = (text?: string) => { + return {text}; + }; + if (filter.meta.alias !== null) { return ( @@ -55,35 +59,35 @@ export function FilterLabel({ filter, valueLabel }: Props) { return ( {prefix} - {filter.meta.key} {existsOperator.message} + {filter.meta.key}: {getValue(`${existsOperator.message}`)} ); case FILTERS.GEO_BOUNDING_BOX: return ( {prefix} - {filter.meta.key}: {valueLabel} + {filter.meta.key}: {getValue(valueLabel)} ); case FILTERS.GEO_POLYGON: return ( {prefix} - {filter.meta.key}: {valueLabel} + {filter.meta.key}: {getValue(valueLabel)} ); case FILTERS.PHRASES: return ( {prefix} - {filter.meta.key} {isOneOfOperator.message} {valueLabel} + {filter.meta.key}: {getValue(`${isOneOfOperator.message} ${valueLabel}`)} ); case FILTERS.QUERY_STRING: return ( {prefix} - {valueLabel} + {getValue(`${valueLabel}`)} ); case FILTERS.PHRASE: @@ -91,14 +95,14 @@ export function FilterLabel({ filter, valueLabel }: Props) { return ( {prefix} - {filter.meta.key}: {valueLabel} + {filter.meta.key}: {getValue(valueLabel)} ); default: return ( {prefix} - {JSON.stringify(filter.query) || filter.meta.value} + {getValue(`${JSON.stringify(filter.query) || filter.meta.value}`)} ); } diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 0febfe807a946d..6b5fd41dc06eab 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -33,6 +33,7 @@ import { toggleFilterPinned, toggleFilterDisabled, } from '../../../common'; +import { getNotifications } from '../../services'; interface Props { id: string; @@ -64,24 +65,41 @@ class FilterItemUI extends Component { public render() { const { filter, id } = this.props; const { negate, disabled } = filter.meta; + let hasError: boolean = false; + + let valueLabel; + try { + valueLabel = getDisplayValueFromFilter(filter, this.props.indexPatterns); + } catch (e) { + getNotifications().toasts.addError(e, { + title: this.props.intl.formatMessage({ + id: 'data.filter.filterBar.labelErrorMessage', + defaultMessage: 'Failed to display filter', + }), + }); + valueLabel = this.props.intl.formatMessage({ + id: 'data.filter.filterBar.labelErrorText', + defaultMessage: 'Error', + }); + hasError = true; + } + const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : ''; + const dataTestSubjValue = filter.meta.value ? `filter-value-${valueLabel}` : ''; + const dataTestSubjDisabled = `filter-${ + this.props.filter.meta.disabled ? 'disabled' : 'enabled' + }`; const classes = classNames( 'globalFilterItem', { - 'globalFilterItem-isDisabled': disabled, + 'globalFilterItem-isDisabled': disabled || hasError, + 'globalFilterItem-isInvalid': hasError, 'globalFilterItem-isPinned': isFilterPinned(filter), 'globalFilterItem-isExcluded': negate, }, this.props.className ); - const valueLabel = getDisplayValueFromFilter(filter, this.props.indexPatterns); - const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : ''; - const dataTestSubjValue = filter.meta.value ? `filter-value-${valueLabel}` : ''; - const dataTestSubjDisabled = `filter-${ - this.props.filter.meta.disabled ? 'disabled' : 'enabled' - }`; - const badge = ( ; - const mockDefaultSearch = jest.fn(() => Promise.resolve({ total: 100, loaded: 0 })); const mockDefaultSearchStrategyProvider = jest.fn(() => Promise.resolve({ @@ -59,4 +57,15 @@ describe('createApi', () => { `"No strategy found for noneByThisName"` ); }); + + it('logs the response if `debug` is set to `true`', async () => { + const spy = jest.spyOn(console, 'log'); + await api.search({ params: {} }); + + expect(spy).not.toBeCalled(); + + await api.search({ debug: true, params: {} }); + + expect(spy).toBeCalled(); + }); }); diff --git a/src/plugins/data/server/search/create_api.ts b/src/plugins/data/server/search/create_api.ts index 798a4b82caaefa..00665b21f2ba75 100644 --- a/src/plugins/data/server/search/create_api.ts +++ b/src/plugins/data/server/search/create_api.ts @@ -31,6 +31,10 @@ export function createApi({ }) { const api: IRouteHandlerSearchContext = { search: async (request, options, strategyName) => { + if (request.debug) { + // eslint-disable-next-line + console.log(JSON.stringify(request, null, 2)); + } const name = strategyName ?? DEFAULT_SEARCH_STRATEGY; const strategyProvider = searchStrategies[name]; if (!strategyProvider) { diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts index 99ccb4dcbebabf..c4b8119f9e0958 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts @@ -51,24 +51,6 @@ describe('ES search strategy', () => { expect(typeof esSearch.search).toBe('function'); }); - it('logs the response if `debug` is set to `true`', async () => { - const spy = jest.spyOn(console, 'log'); - const esSearch = esSearchStrategyProvider( - { - core: mockCoreSetup, - config$: mockConfig$, - }, - mockApiCaller, - mockSearch - ); - - expect(spy).not.toBeCalled(); - - await esSearch.search({ params: {}, debug: true }); - - expect(spy).toBeCalled(); - }); - it('calls the API caller with the params with defaults', async () => { const params = { index: 'logstash-*' }; const esSearch = esSearchStrategyProvider( diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index 20bc964effc02c..26055a3ae41f71 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -21,7 +21,7 @@ import { APICaller } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { ES_SEARCH_STRATEGY } from '../../../common/search'; import { ISearchStrategy, TSearchStrategyProvider } from '../i_search_strategy'; -import { ISearchContext } from '..'; +import { getDefaultSearchParams, ISearchContext } from '..'; export const esSearchStrategyProvider: TSearchStrategyProvider = ( context: ISearchContext, @@ -30,28 +30,18 @@ export const esSearchStrategyProvider: TSearchStrategyProvider { const config = await context.config$.pipe(first()).toPromise(); + const defaultParams = getDefaultSearchParams(config); const params = { - timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`, - ignoreUnavailable: true, // Don't fail if the index/indices don't exist - restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range + ...defaultParams, ...request.params, }; - if (request.debug) { - // eslint-disable-next-line - console.log(JSON.stringify(params, null, 2)); - } - const esSearchResponse = (await caller('search', params, options)) as SearchResponse; + const rawResponse = (await caller('search', params, options)) as SearchResponse; // The above query will either complete or timeout and throw an error. // There is no progress indication on this api. - return { - total: esSearchResponse._shards.total, - loaded: - esSearchResponse._shards.failed + - esSearchResponse._shards.skipped + - esSearchResponse._shards.successful, - rawResponse: esSearchResponse, - }; + const { total, failed, successful } = rawResponse._shards; + const loaded = failed + successful; + return { total, loaded, rawResponse }; }, }; }; diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts new file mode 100644 index 00000000000000..b2341ccc0f3c86 --- /dev/null +++ b/src/plugins/data/server/search/es_search/get_default_search_params.ts @@ -0,0 +1,28 @@ +/* + * 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 { SharedGlobalConfig } from '../../../../../core/server'; + +export function getDefaultSearchParams(config: SharedGlobalConfig) { + return { + timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`, + ignoreUnavailable: true, // Don't fail if the index/indices don't exist + restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range + }; +} diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index e5dcb0c97d7c9c..5a8b3bc94c679c 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -17,6 +17,6 @@ * under the License. */ -export { esSearchStrategyProvider } from './es_search_strategy'; - export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common/search'; +export { esSearchStrategyProvider } from './es_search_strategy'; +export { getDefaultSearchParams } from './get_default_search_params'; diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 298a665fd5b2c9..385e96ee803b64 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -21,8 +21,10 @@ export { ISearchSetup } from './i_search_setup'; export { ISearchContext } from './i_search_context'; -export { IRequestTypesMap, IResponseTypesMap } from './i_search'; +export { ISearch, ICancel, ISearchOptions, IRequestTypesMap, IResponseTypesMap } from './i_search'; export { TStrategyTypes } from './strategy_types'; export { TSearchStrategyProvider } from './i_search_strategy'; + +export { getDefaultSearchParams } from './es_search'; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 34abd57eeacdda..0b5fd8184deb10 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -39,6 +39,7 @@ export { Embeddable, EmbeddableChildPanel, EmbeddableChildPanelProps, + EmbeddableContext, EmbeddableFactory, EmbeddableFactoryNotFoundError, EmbeddableFactoryRenderer, diff --git a/src/plugins/timelion/server/config.ts b/src/plugins/timelion/config.ts similarity index 87% rename from src/plugins/timelion/server/config.ts rename to src/plugins/timelion/config.ts index e76c878c0c6b1f..561fb4de9f58db 100644 --- a/src/plugins/timelion/server/config.ts +++ b/src/plugins/timelion/config.ts @@ -17,9 +17,9 @@ * under the License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; -export const ConfigSchema = schema.object( +export const configSchema = schema.object( { ui: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), graphiteUrls: schema.maybe(schema.arrayOf(schema.string())), @@ -27,3 +27,5 @@ export const ConfigSchema = schema.object( // This option should be removed as soon as we entirely migrate config from legacy Timelion plugin. { allowUnknowns: true } ); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/timelion/kibana.json b/src/plugins/timelion/kibana.json index fe6e425f76c05d..dddfd6c67e6550 100644 --- a/src/plugins/timelion/kibana.json +++ b/src/plugins/timelion/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "configPath": ["timelion"], "server": true, - "ui": false + "ui": true } diff --git a/src/plugins/timelion/public/index.ts b/src/plugins/timelion/public/index.ts new file mode 100644 index 00000000000000..b05c4f8a30b226 --- /dev/null +++ b/src/plugins/timelion/public/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { CoreStart, PluginInitializerContext } from 'kibana/public'; +import { ConfigSchema } from '../config'; + +export const plugin = (initializerContext: PluginInitializerContext) => ({ + setup() {}, + start(core: CoreStart) { + if (initializerContext.config.get().ui.enabled === false) { + core.chrome.navLinks.update('timelion', { hidden: true }); + } + }, +}); diff --git a/src/plugins/timelion/server/index.ts b/src/plugins/timelion/server/index.ts index 690544f0b9f5ca..5d420327f961e2 100644 --- a/src/plugins/timelion/server/index.ts +++ b/src/plugins/timelion/server/index.ts @@ -18,11 +18,18 @@ */ import { PluginInitializerContext } from '../../../../src/core/server'; -import { ConfigSchema } from './config'; +import { configSchema } from '../config'; import { Plugin } from './plugin'; export { PluginSetupContract } from './plugin'; -export const config = { schema: ConfigSchema }; +export const config = { + schema: configSchema, + exposeToBrowser: { + ui: { + enabled: true, + }, + }, +}; export const plugin = (initializerContext: PluginInitializerContext) => new Plugin(initializerContext); diff --git a/src/plugins/timelion/server/lib/config_manager.ts b/src/plugins/timelion/server/lib/config_manager.ts index 60d89f34a4c085..17471ca34f5ba8 100644 --- a/src/plugins/timelion/server/lib/config_manager.ts +++ b/src/plugins/timelion/server/lib/config_manager.ts @@ -19,14 +19,14 @@ import { PluginInitializerContext } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; -import { ConfigSchema } from '../config'; +import { configSchema } from '../../config'; export class ConfigManager { private esShardTimeout: number = 0; private graphiteUrls: string[] = []; constructor(config: PluginInitializerContext['config']) { - config.create>().subscribe(configUpdate => { + config.create>().subscribe(configUpdate => { this.graphiteUrls = configUpdate.graphiteUrls || []; }); diff --git a/src/plugins/timelion/server/plugin.ts b/src/plugins/timelion/server/plugin.ts index 4330bc0ffb357c..40e89008e75620 100644 --- a/src/plugins/timelion/server/plugin.ts +++ b/src/plugins/timelion/server/plugin.ts @@ -26,7 +26,7 @@ import { RecursiveReadonly, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; -import { ConfigSchema } from './config'; +import { configSchema } from '../config'; import loadFunctions from './lib/load_functions'; import { functionsRoute } from './routes/functions'; import { validateEsRoute } from './routes/validate_es'; @@ -48,7 +48,7 @@ export class Plugin { public async setup(core: CoreSetup): Promise> { const config = await this.initializerContext.config - .create>() + .create>() .pipe(first()) .toPromise(); diff --git a/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js b/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js index 10a5de2c2aa770..2b687a70a64613 100644 --- a/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js +++ b/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js @@ -93,7 +93,7 @@ export default function({ getService }) { .expect(400) .then(resp => { expect(resp.body.message).to.contain( - '[request query.look_back]: Value is [0] but it must be equal to or greater than [1].' + '[request query.look_back]: Value must be equal to or greater than [1].' ); })); }); diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 432e83891aa928..2011b9bc274f58 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -23,7 +23,6 @@ export default function({ getService, getPageObjects }) { const log = getService('log'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); - const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); const queryBar = getService('queryBar'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); @@ -188,7 +187,7 @@ export default function({ getService, getPageObjects }) { describe('time zone switch', () => { it('should show bars in the correct time zone after switching', async function() { await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); - await browser.refresh(); + await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); await PageObjects.timePicker.setDefaultAbsoluteRange(); diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index b629e064b39b5e..5055997df642a0 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -11,7 +11,7 @@ if [[ -z "$CODE_COVERAGE" ]] ; then echo " -> Running jest tests" cd "$XPACK_DIR" - checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose --detectOpenHandles + checks-reporter-with-killswitch "X-Pack Jest" node --max-old-space-size=6144 scripts/jest --ci --verbose --detectOpenHandles echo "" echo "" diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index f2af61df73d206..53628ea970fb6f 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -39,7 +39,7 @@ "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], "xpack.taskManager": "legacy/plugins/task_manager", - "xpack.transform": ["legacy/plugins/transform", "plugins/transform"], + "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", "xpack.uptime": "legacy/plugins/uptime", diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 7809734dbf2adb..d5764001a7f180 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -22,6 +22,7 @@ exports[`Home component should render services 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, @@ -61,6 +62,7 @@ exports[`Home component should render traces 1`] = ` }, "notifications": Object { "toasts": Object { + "addDanger": [Function], "addWarning": [Function], }, }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx new file mode 100644 index 00000000000000..418430e37b21e6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut } from '@elastic/eui'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; + +const EmptyBannerCallOut = styled(EuiCallOut)` + margin: ${lightTheme.gutterTypes.gutterSmall}; + /* Add some extra margin so it displays to the right of the controls. */ + margin-left: calc( + ${lightTheme.gutterTypes.gutterLarge} + + ${lightTheme.gutterTypes.gutterExtraLarge} + ); + position: absolute; + z-index: 1; +`; + +export function EmptyBanner() { + return ( + + {i18n.translate('xpack.apm.serviceMap.emptyBanner.message', { + defaultMessage: + "We will map out connected services and external requests if we can detect them. Please make sure you're running the latest version of the APM agent." + })}{' '} + + {i18n.translate('xpack.apm.serviceMap.emptyBanner.docsLink', { + defaultMessage: 'Learn more in the docs' + })} + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx new file mode 100644 index 00000000000000..926f53954e7c6b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.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 { render } from '@testing-library/react'; +import React, { FunctionComponent } from 'react'; +import { License } from '../../../../../../../plugins/licensing/common/license'; +import { LicenseContext } from '../../../context/LicenseContext'; +import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { ServiceMap } from './'; + +const expiredLicense = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'platinum', + status: 'expired', + type: 'platinum', + uid: '1' + } +}); + +const Wrapper: FunctionComponent = ({ children }) => { + return ( + + {children} + + ); +}; + +describe('ServiceMap', () => { + describe('with an inactive license', () => { + it('renders the license banner', async () => { + expect( + ( + await render(, { + wrapper: Wrapper + }).findAllByText(/Platinum/) + ).length + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index d5f0728a7ff128..2942ce64729e7d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -21,14 +21,15 @@ import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; -import { useCallApmApi } from '../../../hooks/useCallApmApi'; import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; import { useLicense } from '../../../hooks/useLicense'; import { useLoadingIndicator } from '../../../hooks/useLoadingIndicator'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; +import { EmptyBanner } from './EmptyBanner'; import { getCytoscapeElements } from './get_cytoscape_elements'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { Popover } from './Popover'; @@ -61,7 +62,6 @@ ${theme.euiColorLightShade}`, const MAX_REQUESTS = 5; export function ServiceMap({ serviceName }: ServiceMapProps) { - const callApmApi = useCallApmApi(); const license = useLicense(); const { search } = useLocation(); const { urlParams, uiFilters } = useUrlParams(); @@ -137,7 +137,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { } } }, - [params, setIsLoading, callApmApi, responses.length, notifications.toasts] + [params, setIsLoading, responses.length, notifications.toasts] ); useEffect(() => { @@ -215,6 +215,9 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { style={cytoscapeDivStyle} > + {serviceName && renderedElements.current.length === 1 && ( + + )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx index 1564f1ae746a99..997df371b51ed2 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx @@ -8,10 +8,9 @@ import React, { useState } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useCallApmApi } from '../../../../../hooks/useCallApmApi'; import { Config } from '../index'; import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { APMClient } from '../../../../../services/rest/createCallApmApi'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; interface Props { @@ -22,7 +21,6 @@ interface Props { export function DeleteButton({ onDeleted, selectedConfig }: Props) { const [isDeleting, setIsDeleting] = useState(false); const { toasts } = useApmPluginContext().core.notifications; - const callApmApi = useCallApmApi(); return ( { setIsDeleting(true); - await deleteConfig(callApmApi, selectedConfig, toasts); + await deleteConfig(selectedConfig, toasts); setIsDeleting(false); onDeleted(); }} @@ -45,7 +43,6 @@ export function DeleteButton({ onDeleted, selectedConfig }: Props) { } async function deleteConfig( - callApmApi: APMClient, selectedConfig: Config, toasts: NotificationsStart['toasts'] ) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx similarity index 82% rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx index ab3accec90d1db..537bdace50e249 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx @@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n'; import { omitAllOption, getOptionLabel -} from '../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../SelectWithPlaceholder'; +} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.selectPlaceholder', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', { defaultMessage: 'Select' } )} -`; @@ -27,7 +27,7 @@ interface Props { onEnvironmentChange: (env: string) => void; } -export function ServiceForm({ +export function ServiceSection({ isReadOnly, serviceName, onServiceNameChange, @@ -60,7 +60,7 @@ export function ServiceForm({ ); const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.alreadyConfiguredOption', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', { defaultMessage: 'already configured' } ); @@ -83,7 +83,7 @@ export function ServiceForm({

{i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceForm.title', + 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', { defaultMessage: 'Service' } )}

@@ -93,13 +93,13 @@ export function ServiceForm({ - ; }) { await callApmApi({ @@ -94,11 +91,11 @@ export function ApmIndices() { const [apmIndices, setApmIndices] = useState>({}); const [isSaving, setIsSaving] = useState(false); - const callApmApiFromHook = useCallApmApi(); - const { data = INITIAL_STATE, status, refetch } = useFetcher( - callApmApi => - callApmApi({ pathname: `/api/apm/settings/apm-index-settings` }), + _callApmApi => + _callApmApi({ + pathname: `/api/apm/settings/apm-index-settings` + }), [] ); @@ -122,10 +119,7 @@ export function ApmIndices() { event.preventDefault(); setIsSaving(true); try { - await saveApmIndices({ - callApmApi: callApmApiFromHook, - apmIndices - }); + await saveApmIndices({ apmIndices }); toasts.addSuccess({ title: i18n.translate( 'xpack.apm.settings.apmIndices.applyChanges.succeeded.title', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx deleted file mode 100644 index 8cb604d3675497..00000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiFieldText, EuiFormRow, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface Props { - label: string; - onLabelChange: (label: string) => void; - url: string; - onURLChange: (url: string) => void; -} - -export const SettingsSection = ({ - label, - onLabelChange, - url, - onURLChange -}: Props) => { - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.title', - { defaultMessage: 'Action' } - )} -

-
- - - { - onLabelChange(e.target.value); - }} - /> - - - { - onURLChange(e.target.value); - }} - /> - - - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx deleted file mode 100644 index d04cdd62c303ba..00000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiPortal, - EuiSpacer, - EuiText, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { SettingsSection } from './SettingsSection'; -import { ServiceForm } from '../../../../../shared/ServiceForm'; - -interface Props { - onClose: () => void; -} - -export const CustomActionsFlyout = ({ onClose }: Props) => { - const [serviceName, setServiceName] = useState(''); - const [environment, setEnvironment] = useState(''); - const [label, setLabel] = useState(''); - const [url, setURL] = useState(''); - return ( - - - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.title', - { - defaultMessage: 'Create custom action' - } - )} -

-
-
- - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.label', - { - defaultMessage: - "This action will be shown in the 'Actions' context menu for the trace and error detail components. You can specify any number of links, but only the first three will be shown, in alphabetical order." - } - )} -

-
- - - - - - -
- - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.close', - { - defaultMessage: 'Close' - } - )} - - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.flyout.save', - { - defaultMessage: 'Save' - } - )} - - - - -
-
- ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx deleted file mode 100644 index f39e4b307b24cc..00000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -export const EmptyPrompt = ({ - onCreateCustomActionClick -}: { - onCreateCustomActionClick: () => void; -}) => { - return ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptTitle', - { - defaultMessage: 'No actions found.' - } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.emptyPromptText', - { - defaultMessage: - "Let's change that! You can add custom actions to the Actions context menu by the trace and error details for each service. This could be linking to a Kibana dashboard or going to your organization's support portal" - } - )} -

- - } - actions={ - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.createCustomAction', - { defaultMessage: 'Create custom action' } - )} - - } - /> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx deleted file mode 100644 index 970de66c64a9aa..00000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { CustomActionsOverview } from '../'; -import { expectTextsInDocument } from '../../../../../../utils/testHelpers'; -import * as hooks from '../../../../../../hooks/useFetcher'; - -describe('CustomActions', () => { - afterEach(() => jest.restoreAllMocks()); - - describe('empty prompt', () => { - it('shows when any actions are available', () => { - // TODO: mock return items - const component = render(); - expectTextsInDocument(component, ['No actions found.']); - }); - it('opens flyout when click to create new action', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: [], - status: 'success' - }); - const { queryByText, getByText } = render(); - expect(queryByText('Service')).not.toBeInTheDocument(); - fireEvent.click(getByText('Create custom action')); - expect(queryByText('Service')).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx deleted file mode 100644 index ae2972f251fc28..00000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiSpacer } from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { useState } from 'react'; -import { ManagedTable } from '../../../../shared/ManagedTable'; -import { Title } from './Title'; -import { EmptyPrompt } from './EmptyPrompt'; -import { CustomActionsFlyout } from './CustomActionsFlyout'; - -export const CustomActionsOverview = () => { - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - - // TODO: change it to correct fields fetched from ES - const columns = [ - { - field: 'actionName', - name: 'Action Name', - truncateText: true - }, - { - field: 'serviceName', - name: 'Service Name' - }, - { - field: 'environment', - name: 'Environment' - }, - { - field: 'lastUpdate', - name: 'Last update' - }, - { - field: 'actions', - name: 'Actions' - } - ]; - - // TODO: change to items fetched from ES. - const items: object[] = []; - - const onCloseFlyout = () => { - setIsFlyoutOpen(false); - }; - - const onCreateCustomActionClick = () => { - setIsFlyoutOpen(true); - }; - - return ( - <> - - - <EuiSpacer size="m" /> - {isFlyoutOpen && <CustomActionsFlyout onClose={onCloseFlyout} />} - {isEmpty(items) ? ( - <EmptyPrompt onCreateCustomActionClick={onCreateCustomActionClick} /> - ) : ( - <ManagedTable - items={items} - columns={columns} - initialPageSize={25} - initialSortField="occurrenceCount" - initialSortDirection="desc" - sortItems={false} - /> - )} - </EuiPanel> - </> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx new file mode 100644 index 00000000000000..415d2557c23c33 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const CreateCustomLinkButton = ({ + onClick +}: { + onClick: () => void; +}) => ( + <EuiButton color="primary" fill onClick={onClick}> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.createCustomLink', + { defaultMessage: 'Create custom link' } + )} + </EuiButton> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx new file mode 100644 index 00000000000000..2b3a5cbe87992c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx @@ -0,0 +1,70 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import React, { useState } from 'react'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; + +interface Props { + onDelete: () => void; + customLinkId: string; +} + +export function DeleteButton({ onDelete, customLinkId }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + <EuiButtonEmpty + color="danger" + isLoading={isDeleting} + iconSide="right" + onClick={async () => { + setIsDeleting(true); + await deleteConfig(customLinkId, toasts); + setIsDeleting(false); + onDelete(); + }} + > + {i18n.translate('xpack.apm.settings.customizeUI.customLink.delete', { + defaultMessage: 'Delete' + })} + </EuiButtonEmpty> + ); +} + +async function deleteConfig( + customLinkId: string, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'DELETE', + params: { + path: { id: customLinkId } + } + }); + toasts.addSuccess({ + iconType: 'trash', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.successed', + { defaultMessage: 'Deleted custom link.' } + ) + }); + } catch (error) { + toasts.addDanger({ + iconType: 'cross', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.delete.failed', + { defaultMessage: 'Custom link could not be deleted' } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx new file mode 100644 index 00000000000000..69fecf25f51437 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -0,0 +1,167 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { + DEFAULT_OPTION, + Filters, + filterSelectOptions, + getSelectOptions +} from './helper'; + +export const FiltersSection = ({ + filters, + onChangeFilters +}: { + filters: Filters; + onChangeFilters: (filters: Filters) => void; +}) => { + const onChangeFilter = (filter: Filters[0], idx: number) => { + const newFilters = [...filters]; + newFilters[idx] = filter; + onChangeFilters(newFilters); + }; + + const onRemoveFilter = (idx: number) => { + // remove without mutating original array + const newFilters = [...filters].splice(idx, 1); + + // if there is only one item left it should not be removed + // but reset to empty + if (isEmpty(newFilters)) { + onChangeFilters([['', '']]); + } else { + onChangeFilters(newFilters); + } + }; + + const handleAddFilter = () => { + onChangeFilters([...filters, ['', '']]); + }; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.title', + { + defaultMessage: 'Filters' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle', + { + defaultMessage: + 'Add additional values within the same field by comma separating values.' + } + )} + </EuiText> + + <EuiSpacer size="s" /> + + {filters.map((filter, idx) => { + const [key, value] = filter; + const filterId = `filter-${idx}`; + const selectOptions = getSelectOptions(filters, idx); + return ( + <EuiFlexGroup key={filterId} gutterSize="s" alignItems="center"> + <EuiFlexItem> + <EuiSelect + aria-label={filterId} + id={filterId} + fullWidth + options={selectOptions} + value={key} + prepend={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.prepend', + { + defaultMessage: 'Field' + } + )} + onChange={e => + onChangeFilter( + [e.target.value as keyof FilterOptions, value], + idx + ) + } + isInvalid={ + !isEmpty(value) && + (isEmpty(key) || key === DEFAULT_OPTION.value) + } + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFieldText + fullWidth + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption.value', + { defaultMessage: 'Value' } + )} + onChange={e => onChangeFilter([key, e.target.value], idx)} + value={value} + isInvalid={!isEmpty(key) && isEmpty(value)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="trash" + onClick={() => onRemoveFilter(idx)} + disabled={!key && filters.length === 1} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); + })} + + <EuiSpacer size="xs" /> + + <AddFilterButton + onClick={handleAddFilter} + // Disable button when user has already added all items available + isDisabled={filters.length === filterSelectOptions.length - 1} + /> + </> + ); +}; + +const AddFilterButton = ({ + onClick, + isDisabled +}: { + onClick: () => void; + isDisabled: boolean; +}) => ( + <EuiButtonEmpty + iconType="plusInCircle" + onClick={onClick} + disabled={isDisabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter', + { + defaultMessage: 'Add another filter' + } + )} + </EuiButtonEmpty> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx new file mode 100644 index 00000000000000..cb272213098127 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DeleteButton } from './DeleteButton'; + +export const FlyoutFooter = ({ + onClose, + isSaving, + onDelete, + customLinkId, + isSaveButtonEnabled +}: { + onClose: () => void; + isSaving: boolean; + onDelete: () => void; + customLinkId?: string; + isSaveButtonEnabled: boolean; +}) => { + return ( + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={onClose} flush="left"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.close', + { + defaultMessage: 'Close' + } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup> + {customLinkId && ( + <EuiFlexItem> + <DeleteButton customLinkId={customLinkId} onDelete={onDelete} /> + </EuiFlexItem> + )} + <EuiFlexItem> + <EuiButton + fill + type="submit" + isLoading={isSaving} + isDisabled={!isSaveButtonEnabled} + > + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.save', + { + defaultMessage: 'Save' + } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx new file mode 100644 index 00000000000000..89f55a6c682ca8 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -0,0 +1,135 @@ +/* + * 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 { + EuiFieldText, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +interface InputField { + name: keyof CustomLink; + label: string; + helpText: string; + placeholder: string; + onChange: (value: string) => void; + value?: string; +} + +interface Props { + label?: string; + onChangeLabel: (label: string) => void; + url?: string; + onChangeUrl: (url: string) => void; +} + +export const LinkSection = ({ + label, + onChangeLabel, + url, + onChangeUrl +}: Props) => { + const inputFields: InputField[] = [ + { + name: 'label', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label', + { + defaultMessage: 'Label' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.helpText', + { + defaultMessage: + 'This is the label shown in the actions context menu. Keep it as short as possible.' + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.placeholder', + { + defaultMessage: 'e.g. Support tickets' + } + ), + value: label, + onChange: onChangeLabel + }, + { + name: 'url', + label: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url', + { + defaultMessage: 'URL' + } + ), + helpText: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', + { + defaultMessage: + 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.', + values: { sample: '{{trace.id}}' } + } + ), + placeholder: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder', + { + defaultMessage: 'e.g. https://www.elastic.co/' + } + ), + value: url, + onChange: onChangeUrl + } + ]; + + return ( + <> + <EuiTitle size="xs"> + <h3> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.action.title', + { + defaultMessage: 'Link' + } + )} + </h3> + </EuiTitle> + <EuiSpacer size="l" /> + {inputFields.map(field => { + return ( + <EuiFormRow + fullWidth + key={field.name} + label={field.label} + helpText={field.helpText} + labelAppend={ + <EuiText size="xs"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.required', + { + defaultMessage: 'Required' + } + )} + </EuiText> + } + > + <EuiFieldText + placeholder={field.placeholder} + name={field.name} + fullWidth + value={field.value} + onChange={e => field.onChange(e.target.value)} + aria-label={field.name} + /> + </EuiFormRow> + ); + })} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts new file mode 100644 index 00000000000000..bb86a251594abf --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { isEmpty, pick } from 'lodash'; +import { + FilterOptions, + filterOptions + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +export type Filters = Array<[keyof FilterOptions | '', string]>; + +interface FilterSelectOption { + value: 'DEFAULT' | keyof FilterOptions; + text: string; +} + +/** + * Converts available filters from the Custom Link to Array of filters. + * e.g. + * customLink = { + * id: '1', + * label: 'foo', + * url: 'http://www.elastic.co', + * service.name: 'opbeans-java', + * transaction.type: 'request' + * } + * + * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * @param customLink + */ +export const convertFiltersToArray = (customLink?: CustomLink): Filters => { + if (customLink) { + const filters = Object.entries(pick(customLink, filterOptions)) as Filters; + if (!isEmpty(filters)) { + return filters; + } + } + return [['', '']]; +}; + +/** + * Converts array of filters into object. + * e.g. + * filters: [['service.name', 'opbeans-java'],['transaction.type', 'request']] + * + * results: { + * 'service.name': 'opbeans-java', + * 'transaction.type': 'request' + * } + * @param filters + */ +export const convertFiltersToObject = (filters: Filters) => { + const convertedFilters = Object.fromEntries( + filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + ); + if (!isEmpty(convertedFilters)) { + return convertedFilters; + } +}; + +export const DEFAULT_OPTION: FilterSelectOption = { + value: 'DEFAULT', + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption', + { defaultMessage: 'Select field...' } + ) +}; + +export const filterSelectOptions: FilterSelectOption[] = [ + DEFAULT_OPTION, + ...filterOptions.map(filter => ({ + value: filter as keyof FilterOptions, + text: filter + })) +]; + +/** + * Returns the options available, removing filters already added, but keeping the selected filter. + * + * @param filters + * @param idx + */ +export const getSelectOptions = (filters: Filters, idx: number) => { + return filterSelectOptions.filter(option => { + const indexUsedFilter = filters.findIndex( + filter => filter[0] === option.value + ); + // Filter out all items already added, besides the one selected in the current filter. + return indexUsedFilter === -1 || idx === indexUsedFilter; + }); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx new file mode 100644 index 00000000000000..88358c888160b4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { FiltersSection } from './FiltersSection'; +import { FlyoutFooter } from './FlyoutFooter'; +import { LinkSection } from './LinkSection'; +import { saveCustomLink } from './saveCustomLink'; +import { convertFiltersToArray, convertFiltersToObject } from './helper'; + +interface Props { + onClose: () => void; + customLinkSelected?: CustomLink; + onSave: () => void; + onDelete: () => void; +} + +export const CustomLinkFlyout = ({ + onClose, + customLinkSelected, + onSave, + onDelete +}: Props) => { + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + + const [label, setLabel] = useState(customLinkSelected?.label || ''); + const [url, setUrl] = useState(customLinkSelected?.url || ''); + const [filters, setFilters] = useState( + convertFiltersToArray(customLinkSelected) + ); + + const isFormValid = !!label && !!url; + + const onSubmit = async ( + event: + | React.FormEvent<HTMLFormElement> + | React.MouseEvent<HTMLButtonElement> + ) => { + event.preventDefault(); + setIsSaving(true); + await saveCustomLink({ + id: customLinkSelected?.id, + label, + url, + filters: convertFiltersToObject(filters), + toasts + }); + setIsSaving(false); + onSave(); + }; + + return ( + <EuiPortal> + <form onSubmit={onSubmit}> + <EuiFlyout ownFocus onClose={onClose} size="m"> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.title', + { + defaultMessage: 'Create link' + } + )} + </h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiText> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label', + { + defaultMessage: + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links and use the filter options to scope them to only appear for specific services. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. TODO: Learn more about it in the docs.' + } + )} + </p> + </EuiText> + + <EuiSpacer size="l" /> + + <LinkSection + label={label} + onChangeLabel={setLabel} + url={url} + onChangeUrl={setUrl} + /> + + <EuiSpacer size="l" /> + + <FiltersSection filters={filters} onChangeFilters={setFilters} /> + </EuiFlyoutBody> + + <FlyoutFooter + isSaveButtonEnabled={isFormValid} + onClose={onClose} + isSaving={isSaving} + onDelete={onDelete} + customLinkId={customLinkSelected?.id} + /> + </EuiFlyout> + </form> + </EuiPortal> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts new file mode 100644 index 00000000000000..f255840e1d7349 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveCustomLink({ + id, + label, + url, + filters, + toasts +}: { + id?: string; + label: string; + url: string; + filters?: { [key: string]: string }; + toasts: NotificationsStart['toasts']; +}) { + try { + const customLink = { + label, + url, + ...filters + }; + if (id) { + await callApmApi({ + pathname: '/api/apm/settings/custom_links/{id}', + method: 'PUT', + params: { + path: { id }, + body: customLink + } + }); + } else { + await callApmApi({ + pathname: '/api/apm/settings/custom_links', + method: 'POST', + params: { + body: customLink + } + }); + } + toasts.addSuccess({ + iconType: 'check', + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.successed', + { defaultMessage: 'Link saved!' } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed', + { defaultMessage: 'Link could not be saved!' } + ), + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.create.failed.message', + { + defaultMessage: + 'Something went wrong when saving the link. Error: "{errorMessage}"', + values: { + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx new file mode 100644 index 00000000000000..f7d8c4baa71e9d --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { units, px } from '../../../../../style/variables'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { ManagedTable } from '../../../../shared/ManagedTable'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; + +interface Props { + items: CustomLink[]; + onCustomLinkSelected: (customLink: CustomLink) => void; +} + +export const CustomLinkTable = ({ + items = [], + onCustomLinkSelected +}: Props) => { + const [searchTerm, setSearchTerm] = useState(''); + + const columns = [ + { + field: 'label', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.name', + { defaultMessage: 'Name' } + ), + truncateText: true + }, + { + field: 'url', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.url', + { defaultMessage: 'URL' } + ), + truncateText: true + }, + { + width: px(160), + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.lastUpdated', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) + }, + { + width: px(units.triple), + name: '', + actions: [ + { + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel', + { defaultMessage: 'Edit' } + ), + description: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription', + { defaultMessage: 'Edit this custom link' } + ), + icon: 'pencil', + color: 'primary', + type: 'icon', + onClick: (customLink: CustomLink) => { + onCustomLinkSelected(customLink); + } + } + ] + } + ]; + + const filteredItems = items.filter(({ label, url }) => { + return ( + label.toLowerCase().includes(searchTerm) || + url.toLowerCase().includes(searchTerm) + ); + }); + + return ( + <> + <EuiSpacer size="m" /> + <EuiFieldSearch + fullWidth + onChange={e => setSearchTerm(e.target.value)} + placeholder={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.searchInput.filter', + { + defaultMessage: 'Filter links by Name and URL...' + } + )} + /> + <EuiSpacer size="s" /> + <ManagedTable + noItemsMessage={ + isEmpty(items) ? ( + <LoadingStatePrompt /> + ) : ( + <NoResultFound value={searchTerm} /> + ) + } + items={filteredItems} + columns={columns} + initialPageSize={10} + initialSortField="@timestamp" + initialSortDirection="desc" + /> + </> + ); +}; + +const NoResultFound = ({ value }: { value: string }) => ( + <EuiFlexGroup justifyContent="spaceAround"> + <EuiFlexItem grow={false}> + <EuiText size="s"> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.noResultFound', + { + defaultMessage: `No results for "{value}".`, + values: { value } + } + )} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx new file mode 100644 index 00000000000000..e75004918f430d --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const EmptyPrompt = ({ + onCreateCustomLinkClick +}: { + onCreateCustomLinkClick: () => void; +}) => { + return ( + <EuiEmptyPrompt + iconType="link" + iconColor="" + title={ + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptTitle', + { + defaultMessage: 'No links found.' + } + )} + </h2> + } + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.emptyPromptText', + { + defaultMessage: + "Let's change that! You can add custom links to the Actions context menu by the transaction details for each service. Create a helpful link to your company's support portal or open a new bug report. Learn more about it in our docs." + } + )} + </p> + </> + } + actions={<CreateCustomLinkButton onClick={onCreateCustomLinkClick} />} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx similarity index 81% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx rename to x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx index d7f90e0919733f..17ec42b3e20161 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx @@ -14,8 +14,8 @@ export const Title = () => ( <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}> <EuiFlexItem grow={false}> <h1> - {i18n.translate('xpack.apm.settings.customizeUI.customActions', { - defaultMessage: 'Custom actions' + {i18n.translate('xpack.apm.settings.customizeUI.customLink', { + defaultMessage: 'Custom Links' })} </h1> </EuiFlexItem> @@ -25,10 +25,10 @@ export const Title = () => ( type="iInCircle" position="top" content={i18n.translate( - 'xpack.apm.settings.customizeUI.customActions.info', + 'xpack.apm.settings.customizeUI.customLink.info', { defaultMessage: - "These actions will be shown in the 'Actions' context menu for the trace and error detail components." + "These links will be shown in the 'Actions' context menu for the transaction detail." } )} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx new file mode 100644 index 00000000000000..f02cc2be8268d0 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx @@ -0,0 +1,251 @@ +/* + * 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 { fireEvent, render, wait } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CustomLinkOverview } from '../'; +import * as hooks from '../../../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + MockApmPluginContextWrapper +} from '../../../../../../utils/testHelpers'; +import * as saveCustomLink from '../CustomLinkFlyout/saveCustomLink'; +import * as apmApi from '../../../../../../services/rest/createCallApmApi'; + +const data = [ + { + id: '1', + label: 'label 1', + url: 'url 1', + 'service.name': 'opbeans-java' + }, + { + id: '2', + label: 'label 2', + url: 'url 2', + 'transaction.type': 'request' + } +]; + +describe('CustomLink', () => { + describe('empty prompt', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + it('shows when no link is available', () => { + const component = render(<CustomLinkOverview />); + expectTextsInDocument(component, ['No links found.']); + }); + it('opens flyout when click to create new link', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('overview', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('shows a table with all custom link', () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expectTextsInDocument(component, [ + 'label 1', + 'url 1', + 'label 2', + 'url 2' + ]); + }); + + it('checks if create custom link button is available and working', () => { + const { queryByText, getByText } = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('Flyout', () => { + const refetch = jest.fn(); + let callApmApiSpy: Function; + let saveCustomLinkSpy: Function; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi'); + saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success', + refetch + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + const openFlyout = () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(component.getByText('Create custom link')); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + return component; + }; + + it('creates a custom link', async () => { + const component = openFlyout(); + const labelInput = component.getByLabelText('label'); + act(() => { + fireEvent.change(labelInput, { + target: { value: 'foo' } + }); + }); + const urlInput = component.getByLabelText('url'); + act(() => { + fireEvent.change(urlInput, { + target: { value: 'bar' } + }); + }); + await act(async () => { + await wait(() => fireEvent.submit(component.getByText('Save'))); + }); + expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); + }); + + it('deletes a custom link', async () => { + const component = render( + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + const editButtons = component.getAllByLabelText('Edit'); + expect(editButtons.length).toEqual(2); + act(() => { + fireEvent.click(editButtons[0]); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + await act(async () => { + await wait(() => fireEvent.click(component.getByText('Delete'))); + }); + expect(callApmApiSpy).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + }); + + describe('Filters', () => { + const addFilterField = ( + component: ReturnType<typeof openFlyout>, + amount: number + ) => { + for (let i = 1; i <= amount; i++) { + fireEvent.click(component.getByText('Add another filter')); + } + }; + it('checks if add filter button is disabled after all elements have been added', () => { + const component = openFlyout(); + expect(component.getAllByText('service.name').length).toEqual(1); + addFilterField(component, 1); + expect(component.getAllByText('service.name').length).toEqual(2); + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + // After 4 items, the button is disabled + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + }); + it('removes items already selected', () => { + const component = openFlyout(); + + const addFieldAndCheck = ( + fieldName: string, + selectValue: string, + addNewFilter: boolean, + optionsExpected: string[] + ) => { + if (addNewFilter) { + addFilterField(component, 1); + } + const field = component.getByLabelText( + fieldName + ) as HTMLSelectElement; + const optionsAvailable = Object.values(field) + .map(option => (option as HTMLOptionElement).text) + .filter(option => option); + + act(() => { + fireEvent.change(field, { + target: { value: selectValue } + }); + }); + expect(field.value).toEqual(selectValue); + expect(optionsAvailable).toEqual(optionsExpected); + }; + + addFieldAndCheck('filter-0', 'transaction.name', false, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name' + ]); + + addFieldAndCheck('filter-1', 'service.name', true, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-2', 'transaction.type', true, [ + 'Select field...', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-3', 'service.environment', true, [ + 'Select field...', + 'service.environment' + ]); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx new file mode 100644 index 00000000000000..bc1882c8c27852 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -0,0 +1,92 @@ +/* + * 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 { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { CustomLinkFlyout } from './CustomLinkFlyout'; +import { CustomLinkTable } from './CustomLinkTable'; +import { EmptyPrompt } from './EmptyPrompt'; +import { Title } from './Title'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; + +export const CustomLinkOverview = () => { + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [customLinkSelected, setCustomLinkSelected] = useState< + CustomLink | undefined + >(); + + const { data: customLinks, status, refetch } = useFetcher( + callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }), + [] + ); + + useEffect(() => { + if (customLinkSelected) { + setIsFlyoutOpen(true); + } + }, [customLinkSelected]); + + const onCloseFlyout = () => { + setCustomLinkSelected(undefined); + setIsFlyoutOpen(false); + }; + + const onCreateCustomLinkClick = () => { + setIsFlyoutOpen(true); + }; + + const showEmptyPrompt = + status === FETCH_STATUS.SUCCESS && isEmpty(customLinks); + + return ( + <> + {isFlyoutOpen && ( + <CustomLinkFlyout + onClose={onCloseFlyout} + customLinkSelected={customLinkSelected} + onSave={() => { + onCloseFlyout(); + refetch(); + }} + onDelete={() => { + onCloseFlyout(); + refetch(); + }} + /> + )} + <EuiPanel> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <Title /> + </EuiFlexItem> + {!showEmptyPrompt && ( + <EuiFlexItem> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <CreateCustomLinkButton onClick={onCreateCustomLinkClick} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + {showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + )} + </EuiPanel> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index 17a4b2f8476795..1cd1298fdd5492 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CustomActionsOverview } from './CustomActionsOverview'; +import { CustomLinkOverview } from './CustomLink'; export const CustomizeUI = () => { return ( @@ -20,7 +20,7 @@ export const CustomizeUI = () => { </h1> </EuiTitle> <EuiSpacer size="l" /> - <CustomActionsOverview /> + <CustomLinkOverview /> </> ); }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx index 7645162ab26559..0e0c318ad32996 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx @@ -23,7 +23,7 @@ export function ElasticDocsLink({ section, path, children, ...rest }: Props) { children(href) ) : ( <EuiLink href={href} {...rest}> - children + {children} </EuiLink> ); } diff --git a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts b/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts deleted file mode 100644 index b28b295d8189e4..00000000000000 --- a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { useApmPluginContext } from './useApmPluginContext'; - -export function useCallApmApi() { - const { http } = useApmPluginContext().core; - - return useMemo(() => { - return createCallApmApi(http); - }, [http]); -} diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index d2202fff996b13..c2530d6982c3b3 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext'; -import { APMClient } from '../services/rest/createCallApmApi'; -import { useCallApmApi } from './useCallApmApi'; +import { APMClient, callApmApi } from '../services/rest/createCallApmApi'; import { useApmPluginContext } from './useApmPluginContext'; import { useLoadingIndicator } from './useLoadingIndicator'; @@ -46,8 +45,6 @@ export function useFetcher<TReturn>( const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); - const callApmApi = useCallApmApi(); - const { dispatchStatus } = useContext(LoadingIndicatorContext); const [result, setResult] = useState<Result<InferResponseType<TReturn>>>({ data: undefined, diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0054f963ba8f2f..0103dd72a3feac 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -39,6 +39,7 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -104,6 +105,7 @@ export class ApmPlugin public start(core: CoreStart) { const i18nCore = core.i18n; const plugins = this.setupPlugins; + createCallApmApi(core.http); // Once we're actually an NP plugin we'll get the config from the // initializerContext like: @@ -157,7 +159,7 @@ export class ApmPlugin ); // create static index pattern and store as saved object. Not needed by APM UI but for legacy reasons in Discover, Dashboard etc. - createStaticIndexPattern(core.http).catch(e => { + createStaticIndexPattern().catch(e => { // eslint-disable-next-line no-console console.log('Error fetching static index pattern', e); }); diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts index 9cca9469bba0ed..2d4fd830031799 100644 --- a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts +++ b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts @@ -5,7 +5,7 @@ */ import * as callApiExports from '../rest/callApi'; -import { createCallApmApi, APMClient } from '../rest/createCallApmApi'; +import { createCallApmApi, callApmApi } from '../rest/createCallApmApi'; import { HttpSetup } from 'kibana/public'; const callApi = jest @@ -13,9 +13,8 @@ const callApi = jest .mockImplementation(() => Promise.resolve(null)); describe('callApmApi', () => { - let callApmApi: APMClient; beforeEach(() => { - callApmApi = createCallApmApi({} as HttpSetup); + createCallApmApi({} as HttpSetup); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts index 220320216788a5..2fffb40d353fc7 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts @@ -19,8 +19,14 @@ export type APMClientOptions = Omit<FetchOptions, 'query' | 'body'> & { }; }; -export const createCallApmApi = (http: HttpSetup) => - ((options: APMClientOptions) => { +export let callApmApi: APMClient = () => { + throw new Error( + 'callApmApi has to be initialized before used. Call createCallApmApi first.' + ); +}; + +export function createCallApmApi(http: HttpSetup) { + callApmApi = ((options: APMClientOptions) => { const { pathname, params = {}, ...opts } = options; const path = (params.path || {}) as Record<string, any>; @@ -36,3 +42,4 @@ export const createCallApmApi = (http: HttpSetup) => query: params.query }); }) as APMClient; +} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts index 8e1234dd55e69b..1efcc98bbbd665 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'kibana/public'; -import { createCallApmApi } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; -export const createStaticIndexPattern = async (http: HttpSetup) => { - const callApmApi = createCallApmApi(http); +export const createStaticIndexPattern = async () => { return await callApmApi({ method: 'POST', pathname: '/api/apm/index_pattern/static' diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts index 5e64d7e1ce716a..1c618098b36e3e 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts @@ -16,7 +16,7 @@ import { } from '../../../../../../plugins/apm/common/ml_job_constants'; import { callApi } from './callApi'; import { ESFilter } from '../../../../../../plugins/apm/typings/elasticsearch'; -import { createCallApmApi, APMClient } from './createCallApmApi'; +import { callApmApi } from './createCallApmApi'; interface MlResponseItem { id: string; @@ -36,7 +36,6 @@ interface StartedMLJobApiResponse { } async function getTransactionIndices(http: HttpSetup) { - const callApmApi: APMClient = createCallApmApi(http); const indices = await callApmApi({ method: 'GET', pathname: `/api/apm/settings/apm-indices` diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index dec2257746e50f..4ee45f7b3330bf 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -29,6 +29,7 @@ import { ApmPluginContextValue } from '../context/ApmPluginContext'; import { ConfigSchema } from '../new-platform/plugin'; +import { createCallApmApi } from '../services/rest/createCallApmApi'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -118,6 +119,7 @@ interface MockSetup { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; }; } @@ -162,7 +164,8 @@ export async function inspectSearchParams( 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; @@ -195,7 +198,8 @@ const mockCore = { }, notifications: { toasts: { - addWarning: () => {} + addWarning: () => {}, + addDanger: () => {} } } }; @@ -222,6 +226,9 @@ export function MockApmPluginContextWrapper({ children?: ReactNode; value?: ApmPluginContextValue; }) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } return ( <ApmPluginContext.Provider value={{ diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index cf0c76be4580d1..63dbae55790a3e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; -import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index bc30ca858bd504..78240eee7ce130 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -7,7 +7,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { TimeRange } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts index 294d6124c7e339..67356dae5b3e33 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedSearch } from './saved_search'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts index a351bcb46cdd3f..87dc7eb5e814cf 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts @@ -12,7 +12,7 @@ import { EmbeddableExpression, } from '../../expression_types'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; import { Filter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 49b4b77de763b3..9c3e80bc22af18 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 0315a1f480911b..5b612b7cbd6667 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -11,7 +11,7 @@ import { EmbeddableExpressionType, EmbeddableExpression, } from '../../expression_types'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; import { Filter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx index ff22d68772efe2..9bdc8e6308e07d 100644 --- a/x-pack/legacy/plugins/canvas/public/application.tsx +++ b/x-pack/legacy/plugins/canvas/public/application.tsx @@ -23,7 +23,7 @@ export const renderApp = ( canvasStore: Store ) => { ReactDOM.render( - <KibanaContextProvider services={{ ...coreStart, ...plugins }}> + <KibanaContextProvider services={{ ...plugins, ...coreStart }}> <I18nProvider> <Provider store={canvasStore}> <App /> diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js b/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js new file mode 100644 index 00000000000000..2dc64477535263 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js @@ -0,0 +1,21 @@ +/* + * 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 { getESFilter } from './get_es_filter'; + +const compact = arr => (Array.isArray(arr) ? arr.filter(val => Boolean(val)) : []); + +export function buildBoolArray(canvasQueryFilterArray) { + return compact( + canvasQueryFilterArray.map(clause => { + try { + return getESFilter(clause); + } catch (e) { + return; + } + }) + ); +} diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts rename to x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts similarity index 73% rename from x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts rename to x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts index 05d4c6570bcfbf..1a5d2119a94b67 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts @@ -7,17 +7,11 @@ import { Filter } from '../../types'; // @ts-ignore Untyped Local import { buildBoolArray } from './build_bool_array'; - -// TODO: We should be importing from `data/server` below instead of `data/common`, but -// need to keep `data/common` since the contents of this file are currently imported -// by the browser. This file should probably be refactored so that the pieces required -// on the client live in a `public` directory instead. See kibana/issues/52343 -// eslint-disable-next-line @kbn/eslint/no-restricted-paths import { TimeRange, esFilters, Filter as DataFilter, -} from '../../../../../../src/plugins/data/server'; +} from '../../../../../../src/plugins/data/public'; export interface EmbeddableFilterInput { filters: DataFilter[]; diff --git a/x-pack/legacy/plugins/canvas/public/lib/filters.js b/x-pack/legacy/plugins/canvas/public/lib/filters.js new file mode 100644 index 00000000000000..afa58c7ee30c2d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/lib/filters.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + TODO: This could be pluggable +*/ + +export function time(filter) { + if (!filter.column) { + throw new Error('column is required for Elasticsearch range filters'); + } + return { + range: { + [filter.column]: { gte: filter.from, lte: filter.to }, + }, + }; +} + +export function luceneQueryString(filter) { + return { + query_string: { + query: filter.query || '*', + }, + }; +} + +export function exactly(filter) { + return { + term: { + [filter.column]: { + value: filter.value, + }, + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js b/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js new file mode 100644 index 00000000000000..e8a4d704118e8b --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + boolArray is the array of bool filter clauses to push filters into. Usually this would be + the value of must, should or must_not. + filter is the abstracted canvas filter. +*/ + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import * as filters from './filters'; + +export function getESFilter(filter) { + if (!filters[filter.type]) { + throw new Error(`Unknown filter type: ${filter.type}`); + } + + try { + return filters[filter.type](filter); + } catch (e) { + throw new Error(`Could not create elasticsearch filter from ${filter.type}`); + } +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 0e256d0ab181b5..4736dd75831e46 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -24,11 +24,10 @@ import { FrameLayout } from './frame_layout'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. -async function waitForPromises(n = 3) { - for (let i = 0; i < n; ++i) { - await Promise.resolve(); - } -} +const waitForPromises = async () => + act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -102,7 +101,7 @@ describe('editor_frame', () => { }); describe('initialization', () => { - it('should initialize initial datasource', () => { + it('should initialize initial datasource', async () => { act(() => { mount( <EditorFrame @@ -119,6 +118,7 @@ describe('editor_frame', () => { /> ); }); + await waitForPromises(); expect(mockDatasource.initialize).toHaveBeenCalled(); }); @@ -145,7 +145,7 @@ describe('editor_frame', () => { expect(mockDatasource.initialize).not.toHaveBeenCalled(); }); - it('should initialize all datasources with state from doc', () => { + it('should initialize all datasources with state from doc', async () => { const mockDatasource3 = createMockDatasource(); const datasource1State = { datasource1: '' }; const datasource2State = { datasource2: '' }; @@ -185,13 +185,13 @@ describe('editor_frame', () => { /> ); }); - + await waitForPromises(); expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State); expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State); expect(mockDatasource3.initialize).not.toHaveBeenCalled(); }); - it('should not render something before all datasources are initialized', () => { + it('should not render something before all datasources are initialized', async () => { act(() => { mount( <EditorFrame @@ -211,6 +211,7 @@ describe('editor_frame', () => { expect(mockVisualization.renderLayerConfigPanel).not.toHaveBeenCalled(); expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); + await waitForPromises(); }); it('should not initialize visualization before datasource is initialized', async () => { @@ -294,7 +295,9 @@ describe('editor_frame', () => { await waitForPromises(); - mockVisualization.initialize.mock.calls[0][0].addNewLayer(); + act(() => { + mockVisualization.initialize.mock.calls[0][0].addNewLayer(); + }); expect(mockDatasource2.insertLayer).toHaveBeenCalledWith(initialState, expect.anything()); }); @@ -325,7 +328,9 @@ describe('editor_frame', () => { await waitForPromises(); - mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']); + act(() => { + mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']); + }); expect(mockDatasource2.removeLayer).toHaveBeenCalledWith(initialState, 'abc'); expect(mockDatasource2.removeLayer).toHaveBeenCalledWith({ removed: true }, 'def'); @@ -989,6 +994,7 @@ describe('editor_frame', () => { '[data-test-subj="datasource-switch-testDatasource2"]' ) as HTMLButtonElement).click(); }); + await waitForPromises(); expect(mockDatasource2.initialize).toHaveBeenCalled(); }); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx index 929b4667aeb667..92a14963ff0b61 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { ReactExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; import { @@ -22,7 +23,10 @@ import { Ast } from '@kbn/interpreter/common'; import { coreMock } from 'src/core/public/mocks'; import { esFilters, IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public'; -const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); +const waitForPromises = async () => + act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); describe('workspace_panel', () => { let mockVisualization: jest.Mocked<Visualization>; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 46a8304cc395e9..1a38ffa44f6f2f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -16,7 +16,10 @@ import { IndexPattern } from './types'; jest.mock('ui/new_platform'); -const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); +const waitForPromises = async () => + act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); describe('IndexPattern Field Item', () => { let defaultProps: FieldItemProps; diff --git a/x-pack/legacy/plugins/maps/common/constants.ts b/x-pack/legacy/plugins/maps/common/constants.ts index 4f1b3223967a58..53289fbbc9005a 100644 --- a/x-pack/legacy/plugins/maps/common/constants.ts +++ b/x-pack/legacy/plugins/maps/common/constants.ts @@ -55,10 +55,10 @@ export const ES_SEARCH = 'ES_SEARCH'; export const ES_PEW_PEW = 'ES_PEW_PEW'; export const EMS_XYZ = 'EMS_XYZ'; // identifies a custom TMS source. Name is a little unfortunate. -export const FIELD_ORIGIN = { - SOURCE: 'source', - JOIN: 'join', -}; +export enum FIELD_ORIGIN { + SOURCE = 'source', + JOIN = 'join', +} export const SOURCE_DATA_ID_ORIGIN = 'source'; export const META_ID_ORIGIN_SUFFIX = 'meta'; @@ -139,6 +139,8 @@ export enum GRID_RESOLUTION { MOST_FINE = 'MOST_FINE', } +export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage'; + export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', { defaultMessage: 'count', }); diff --git a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts index f342260c3e7a41..f03f828200bbd9 100644 --- a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts @@ -40,8 +40,8 @@ export type AbstractESAggDescriptor = AbstractESSourceDescriptor & { }; export type ESGeoGridSourceDescriptor = AbstractESAggDescriptor & { - requestType: RENDER_AS; - resolution: GRID_RESOLUTION; + requestType?: RENDER_AS; + resolution?: GRID_RESOLUTION; }; export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & { diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js deleted file mode 100644 index 27ab8fc5bfb3ae..00000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { AbstractField } from './field'; -import { AGG_TYPE } from '../../../common/constants'; -import { isMetricCountable } from '../util/is_metric_countable'; -import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property'; -import { getField, addFieldToDSL } from '../util/es_agg_utils'; - -export class ESAggMetricField extends AbstractField { - static type = 'ES_AGG'; - - constructor({ label, source, aggType, esDocField, origin }) { - super({ source, origin }); - this._label = label; - this._aggType = aggType; - this._esDocField = esDocField; - } - - getName() { - return this._source.getAggKey(this.getAggType(), this.getRootName()); - } - - getRootName() { - return this._getESDocFieldName(); - } - - async getLabel() { - return this._label - ? this._label - : this._source.getAggLabel(this.getAggType(), this.getRootName()); - } - - getAggType() { - return this._aggType; - } - - isValid() { - return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField; - } - - async getDataType() { - return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; - } - - _getESDocFieldName() { - return this._esDocField ? this._esDocField.getName() : ''; - } - - getRequestDescription() { - return this.getAggType() !== AGG_TYPE.COUNT - ? `${this.getAggType()} ${this.getRootName()}` - : AGG_TYPE.COUNT; - } - - async createTooltipProperty(value) { - const indexPattern = await this._source.getIndexPattern(); - return new ESAggMetricTooltipProperty( - this.getName(), - await this.getLabel(), - value, - indexPattern, - this - ); - } - - getValueAggDsl(indexPattern) { - const field = getField(indexPattern, this.getRootName()); - const aggType = this.getAggType(); - const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {}; - return { - [aggType]: addFieldToDSL(aggBody, field), - }; - } - - supportsFieldMeta() { - // count and sum aggregations are not within field bounds so they do not support field meta. - return !isMetricCountable(this.getAggType()); - } - - canValueBeFormatted() { - // Do not use field formatters for counting metrics - return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType()); - } - - async getOrdinalFieldMetaRequest(config) { - return this._esDocField.getOrdinalFieldMetaRequest(config); - } - - async getCategoricalFieldMetaRequest() { - return this._esDocField.getCategoricalFieldMetaRequest(); - } -} diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js deleted file mode 100644 index aeeffd63607eeb..00000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESAggMetricField } from './es_agg_field'; -import { AGG_TYPE } from '../../../common/constants'; - -describe('supportsFieldMeta', () => { - test('Non-counting aggregations should support field meta', () => { - const avgMetric = new ESAggMetricField({ aggType: AGG_TYPE.AVG }); - expect(avgMetric.supportsFieldMeta()).toBe(true); - const maxMetric = new ESAggMetricField({ aggType: AGG_TYPE.MAX }); - expect(maxMetric.supportsFieldMeta()).toBe(true); - const minMetric = new ESAggMetricField({ aggType: AGG_TYPE.MIN }); - expect(minMetric.supportsFieldMeta()).toBe(true); - }); - - test('Counting aggregations should not support field meta', () => { - const countMetric = new ESAggMetricField({ aggType: AGG_TYPE.COUNT }); - expect(countMetric.supportsFieldMeta()).toBe(false); - const sumMetric = new ESAggMetricField({ aggType: AGG_TYPE.SUM }); - expect(sumMetric.supportsFieldMeta()).toBe(false); - const uniqueCountMetric = new ESAggMetricField({ aggType: AGG_TYPE.UNIQUE_COUNT }); - expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); - }); -}); diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts new file mode 100644 index 00000000000000..7a65b5f9f6b46e --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESAggField, esAggFieldsFactory } from './es_agg_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; +import { IESAggSource } from '../sources/es_agg_source'; +import { IIndexPattern } from 'src/plugins/data/public'; + +const mockIndexPattern = { + title: 'wildIndex', + fields: [ + { + name: 'foo*', + }, + ], +} as IIndexPattern; + +const mockEsAggSource = { + getAggKey: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_key'; + }, + getAggLabel: (aggType: AGG_TYPE, fieldName: string) => { + return 'agg_label'; + }, + getIndexPattern: async () => { + return mockIndexPattern; + }, +} as IESAggSource; + +const defaultParams = { + label: 'my agg field', + source: mockEsAggSource, + aggType: AGG_TYPE.COUNT, + origin: FIELD_ORIGIN.SOURCE, +}; + +describe('supportsFieldMeta', () => { + test('Non-counting aggregations should support field meta', () => { + const avgMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.AVG }); + expect(avgMetric.supportsFieldMeta()).toBe(true); + const maxMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MAX }); + expect(maxMetric.supportsFieldMeta()).toBe(true); + const minMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MIN }); + expect(minMetric.supportsFieldMeta()).toBe(true); + const termsMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.TERMS }); + expect(termsMetric.supportsFieldMeta()).toBe(true); + }); + + test('Counting aggregations should not support field meta', () => { + const countMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.COUNT }); + expect(countMetric.supportsFieldMeta()).toBe(false); + const sumMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.SUM }); + expect(sumMetric.supportsFieldMeta()).toBe(false); + const uniqueCountMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.UNIQUE_COUNT }); + expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); + }); +}); + +describe('esAggFieldsFactory', () => { + test('Should only create top terms field when term field is not provided', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(1); + }); + + test('Should create top terms and top terms percentage fields', () => { + const fields = esAggFieldsFactory( + { type: AGG_TYPE.TERMS, field: 'myField' }, + mockEsAggSource, + FIELD_ORIGIN.SOURCE + ); + expect(fields.length).toBe(2); + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts new file mode 100644 index 00000000000000..9f08200442fea4 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts @@ -0,0 +1,169 @@ +/* + * 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 { IndexPattern } from 'src/plugins/data/public'; +import { IField } from './field'; +import { AggDescriptor } from '../../../common/descriptor_types'; +import { IESAggSource } from '../sources/es_agg_source'; +import { IVectorSource } from '../sources/vector_source'; +// @ts-ignore +import { ESDocField } from './es_doc_field'; +import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants'; +import { isMetricCountable } from '../util/is_metric_countable'; +// @ts-ignore +import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property'; +import { getField, addFieldToDSL } from '../util/es_agg_utils'; +import { TopTermPercentageField } from './top_term_percentage_field'; + +export interface IESAggField extends IField { + getValueAggDsl(indexPattern: IndexPattern): unknown | null; + getBucketCount(): number; +} + +export class ESAggField implements IESAggField { + static type = 'ES_AGG'; + + private _source: IESAggSource; + private _origin: FIELD_ORIGIN; + private _label?: string; + private _aggType: AGG_TYPE; + private _esDocField?: unknown; + + constructor({ + label, + source, + aggType, + esDocField, + origin, + }: { + label?: string; + source: IESAggSource; + aggType: AGG_TYPE; + esDocField?: unknown; + origin: FIELD_ORIGIN; + }) { + this._source = source; + this._origin = origin; + this._label = label; + this._aggType = aggType; + this._esDocField = esDocField; + } + + getSource(): IVectorSource { + return this._source; + } + + getOrigin(): FIELD_ORIGIN { + return this._origin; + } + + getName(): string { + return this._source.getAggKey(this.getAggType(), this.getRootName()); + } + + getRootName(): string { + return this._getESDocFieldName(); + } + + async getLabel(): Promise<string> { + return this._label + ? this._label + : this._source.getAggLabel(this.getAggType(), this.getRootName()); + } + + getAggType(): AGG_TYPE { + return this._aggType; + } + + isValid(): boolean { + return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField; + } + + async getDataType(): Promise<string> { + return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number'; + } + + _getESDocFieldName(): string { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField ? this._esDocField.getName() : ''; + } + + async createTooltipProperty(value: number | string): Promise<unknown> { + const indexPattern = await this._source.getIndexPattern(); + return new ESAggMetricTooltipProperty( + this.getName(), + await this.getLabel(), + value, + indexPattern, + this + ); + } + + getValueAggDsl(indexPattern: IndexPattern): unknown | null { + if (this.getAggType() === AGG_TYPE.COUNT) { + return null; + } + + const field = getField(indexPattern, this.getRootName()); + const aggType = this.getAggType(); + const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {}; + return { + [aggType]: addFieldToDSL(aggBody, field), + }; + } + + getBucketCount(): number { + // terms aggregation increases the overall number of buckets per split bucket + return this.getAggType() === AGG_TYPE.TERMS ? 1 : 0; + } + + supportsFieldMeta(): boolean { + // count and sum aggregations are not within field bounds so they do not support field meta. + return !isMetricCountable(this.getAggType()); + } + + canValueBeFormatted(): boolean { + // Do not use field formatters for counting metrics + return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType()); + } + + async getOrdinalFieldMetaRequest(): Promise<unknown> { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField.getOrdinalFieldMetaRequest(); + } + + async getCategoricalFieldMetaRequest(): Promise<unknown> { + // TODO remove when esDocField is typed + // @ts-ignore + return this._esDocField.getCategoricalFieldMetaRequest(); + } +} + +export function esAggFieldsFactory( + aggDescriptor: AggDescriptor, + source: IESAggSource, + origin: FIELD_ORIGIN +): IESAggField[] { + const aggField = new ESAggField({ + label: aggDescriptor.label, + esDocField: aggDescriptor.field + ? new ESDocField({ fieldName: aggDescriptor.field, source }) + : null, + aggType: aggDescriptor.type, + source, + origin, + }); + + const aggFields: IESAggField[] = [aggField]; + + if (aggDescriptor.field && aggDescriptor.type === AGG_TYPE.TERMS) { + aggFields.push(new TopTermPercentageField(aggField)); + } + + return aggFields; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts index 57a916e93ffe0a..f7c27fec1c6c7e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts +++ b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts @@ -13,12 +13,15 @@ export interface IField { canValueBeFormatted(): boolean; getLabel(): Promise<string>; getDataType(): Promise<string>; + getSource(): IVectorSource; + getOrigin(): FIELD_ORIGIN; + isValid(): boolean; } export class AbstractField implements IField { private _fieldName: string; private _source: IVectorSource; - private _origin: string; + private _origin: FIELD_ORIGIN; constructor({ fieldName, @@ -27,7 +30,7 @@ export class AbstractField implements IField { }: { fieldName: string; source: IVectorSource; - origin: string; + origin: FIELD_ORIGIN; }) { this._fieldName = fieldName; this._source = source; @@ -66,7 +69,7 @@ export class AbstractField implements IField { throw new Error('must implement Field#createTooltipProperty'); } - getOrigin(): string { + getOrigin(): FIELD_ORIGIN { return this._origin; } @@ -74,7 +77,7 @@ export class AbstractField implements IField { return false; } - async getOrdinalFieldMetaRequest(/* config */): Promise<unknown> { + async getOrdinalFieldMetaRequest(): Promise<unknown> { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts new file mode 100644 index 00000000000000..cadf325652370c --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts @@ -0,0 +1,70 @@ +/* + * 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 { IESAggField } from './es_agg_field'; +import { IVectorSource } from '../sources/vector_source'; +// @ts-ignore +import { TooltipProperty } from '../tooltips/tooltip_property'; +import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; +import { FIELD_ORIGIN } from '../../../common/constants'; + +export class TopTermPercentageField implements IESAggField { + private _topTermAggField: IESAggField; + + constructor(topTermAggField: IESAggField) { + this._topTermAggField = topTermAggField; + } + + getSource(): IVectorSource { + return this._topTermAggField.getSource(); + } + + getOrigin(): FIELD_ORIGIN { + return this._topTermAggField.getOrigin(); + } + + getName(): string { + return `${this._topTermAggField.getName()}${TOP_TERM_PERCENTAGE_SUFFIX}`; + } + + getRootName(): string { + // top term percentage is a derived value so it has no root field + return ''; + } + + async getLabel(): Promise<string> { + const baseLabel = await this._topTermAggField.getLabel(); + return `${baseLabel}%`; + } + + isValid(): boolean { + return this._topTermAggField.isValid(); + } + + async getDataType(): Promise<string> { + return 'number'; + } + + async createTooltipProperty(value: unknown): Promise<unknown> { + return new TooltipProperty(this.getName(), await this.getLabel(), value); + } + + getValueAggDsl(): null { + return null; + } + + getBucketCount(): number { + return 0; + } + + supportsFieldMeta(): boolean { + return false; + } + + canValueBeFormatted(): boolean { + return false; + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts new file mode 100644 index 00000000000000..a91bb4a8bb1a7b --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts @@ -0,0 +1,19 @@ +/* + * 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 { IESSource } from './es_source'; +import { AbstractESSource } from './es_source'; +import { AGG_TYPE } from '../../../common/constants'; + +export interface IESAggSource extends IESSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; +} + +export class AbstractESAggSource extends AbstractESSource implements IESAggSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js index 775535d9e2299c..62f3369ceb3a36 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; import { AbstractESSource } from './es_source'; -import { ESAggMetricField } from '../fields/es_agg_field'; -import { ESDocField } from '../fields/es_doc_field'; +import { esAggFieldsFactory } from '../fields/es_agg_field'; + import { AGG_TYPE, COUNT_PROP_LABEL, @@ -20,20 +20,14 @@ export const AGG_DELIMITER = '_of_'; export class AbstractESAggSource extends AbstractESSource { constructor(descriptor, inspectorAdapters) { super(descriptor, inspectorAdapters); - this._metricFields = this._descriptor.metrics - ? this._descriptor.metrics.map(metric => { - const esDocField = metric.field - ? new ESDocField({ fieldName: metric.field, source: this }) - : null; - return new ESAggMetricField({ - label: metric.label, - esDocField: esDocField, - aggType: metric.type, - source: this, - origin: this.getOriginForField(), - }); - }) - : []; + this._metricFields = []; + if (this._descriptor.metrics) { + this._descriptor.metrics.forEach(aggDescriptor => { + this._metricFields.push( + ...esAggFieldsFactory(aggDescriptor, this, this.getOriginForField()) + ); + }); + } } getFieldByName(name) { @@ -61,16 +55,9 @@ export class AbstractESAggSource extends AbstractESSource { getMetricFields() { const metrics = this._metricFields.filter(esAggField => esAggField.isValid()); - if (metrics.length === 0) { - metrics.push( - new ESAggMetricField({ - aggType: AGG_TYPE.COUNT, - source: this, - origin: this.getOriginForField(), - }) - ); - } - return metrics; + return metrics.length === 0 + ? esAggFieldsFactory({ type: AGG_TYPE.COUNT }, this, this.getOriginForField()) + : metrics; } getAggKey(aggType, fieldName) { @@ -93,13 +80,12 @@ export class AbstractESAggSource extends AbstractESSource { getValueAggsDsl(indexPattern) { const valueAggsDsl = {}; - this.getMetricFields() - .filter(esAggMetric => { - return esAggMetric.getAggType() !== AGG_TYPE.COUNT; - }) - .forEach(esAggMetric => { + this.getMetricFields().forEach(esAggMetric => { + const aggDsl = esAggMetric.getValueAggDsl(indexPattern); + if (aggDsl) { valueAggsDsl[esAggMetric.getName()] = esAggMetric.getValueAggDsl(indexPattern); - }); + } + }); return valueAggsDsl; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts index a8223c36df349a..e79d8e09fce9b6 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts @@ -53,6 +53,7 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -79,6 +80,7 @@ describe('convertCompositeRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -125,6 +127,7 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); @@ -151,6 +154,7 @@ describe('convertRegularRespToGeoJson', () => { avg_of_bytes: 5359.2307692307695, doc_count: 65, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 25, }, type: 'Feature', }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts new file mode 100644 index 00000000000000..652409b61fd722 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AbstractESAggSource } from '../es_agg_source'; +import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; + +export class ESGeoGridSource extends AbstractESAggSource { + constructor(sourceDescriptor: ESGeoGridSourceDescriptor, inspectorAdapters: unknown); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index b2463275dad0a7..4987d052b8ab70 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -20,7 +20,6 @@ import { COLOR_GRADIENTS } from '../../styles/color_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { - AGG_TYPE, DEFAULT_MAX_BUCKETS_LIMIT, SOURCE_DATA_ID_ORIGIN, ES_GEO_GRID, @@ -297,10 +296,7 @@ export class ESGeoGridSource extends AbstractESAggSource { let bucketsPerGrid = 1; this.getMetricFields().forEach(metricField => { - if (metricField.getAggType() === AGG_TYPE.TERMS) { - // each terms aggregation increases the overall number of buckets per grid - bucketsPerGrid++; - } + bucketsPerGrid += metricField.getBucketCount(); }); const features = diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts index 5fbd5a3ad20c0e..14c62aa0207feb 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts @@ -62,6 +62,7 @@ it('Should convert elasticsearch aggregation response into feature collection of avg_of_FlightDelayMin: 3, doc_count: 1, terms_of_Carrier: 'ES-Air', + terms_of_Carrier__percentage: 100, }, type: 'Feature', }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 2aaaad15d6321e..25c4fae89f0247 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -5,5 +5,13 @@ */ import { AbstractVectorSource } from './vector_source'; +import { IVectorSource } from './vector_source'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; -export class AbstractESSource extends AbstractVectorSource {} +export interface IESSource extends IVectorSource { + getIndexPattern(): Promise<IndexPattern>; +} + +export class AbstractESSource extends AbstractVectorSource implements IESSource { + getIndexPattern(): Promise<IndexPattern>; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js index 30f60f543d38df..c12b4befc0684d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js @@ -105,7 +105,13 @@ export class ESTermSource extends AbstractESAggSource { requestName: `${this._descriptor.indexPatternTitle}.${this._termField.getName()}`, searchSource, registerCancelCallback, - requestDescription: this._getRequestDescription(leftSourceName, leftFieldName), + requestDescription: i18n.translate('xpack.maps.source.esJoin.joinDescription', { + defaultMessage: `Elasticsearch terms aggregation request, left source: {leftSource}, right source: {rightSource}`, + values: { + leftSource: `${leftSourceName}:${leftFieldName}`, + rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, + }, + }), }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); @@ -118,30 +124,6 @@ export class ESTermSource extends AbstractESAggSource { return false; } - _getRequestDescription(leftSourceName, leftFieldName) { - const metrics = this.getMetricFields().map(esAggMetric => esAggMetric.getRequestDescription()); - const joinStatement = []; - joinStatement.push( - i18n.translate('xpack.maps.source.esJoin.joinLeftDescription', { - defaultMessage: `Join {leftSourceName}:{leftFieldName} with`, - values: { leftSourceName, leftFieldName }, - }) - ); - joinStatement.push(`${this._descriptor.indexPatternTitle}:${this._termField.getName()}`); - joinStatement.push( - i18n.translate('xpack.maps.source.esJoin.joinMetricsDescription', { - defaultMessage: `for metrics {metrics}`, - values: { metrics: metrics.join(',') }, - }) - ); - return i18n.translate('xpack.maps.source.esJoin.joinDescription', { - defaultMessage: `Elasticsearch terms aggregation request for {description}`, - values: { - description: joinStatement.join(' '), - }, - }); - } - async getDisplayName() { //no need to localize. this is never rendered. return `es_table ${this._descriptor.indexPatternId}`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js index 3952aacf03b334..8369ca562e14b3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js @@ -54,7 +54,7 @@ export class AbstractVectorSource extends AbstractSource { * factory function creating a new field-instance * @param fieldName * @param label - * @returns {ESAggMetricField} + * @returns {IField} */ createField() { throw new Error(`Should implemement ${this.constructor.type} ${this}`); @@ -64,7 +64,7 @@ export class AbstractVectorSource extends AbstractSource { * Retrieves a field. This may be an existing instance. * @param fieldName * @param label - * @returns {ESAggMetricField} + * @returns {IField} */ getFieldByName(name) { return this.createField({ fieldName: name }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 19e80f330378b9..7b94e58f0e7d48 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -152,10 +152,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { async getFieldMetaRequest() { if (this.isOrdinal()) { - const fieldMetaOptions = this.getFieldMetaOptions(); - return this._field.getOrdinalFieldMetaRequest({ - sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA), - }); + return this._field.getOrdinalFieldMetaRequest(); } else if (this.isCategorical()) { return this._field.getCategoricalFieldMetaRequest(); } else { diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts index 201d6907981a2b..445a7621194b7f 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts @@ -19,19 +19,34 @@ describe('extractPropertiesFromBucket', () => { }); }); - test('Should extract bucket aggregation values', () => { + test('Should extract top bucket aggregation value and percentage', () => { const properties = extractPropertiesFromBucket({ + doc_count: 3, 'terms_of_machine.os.keyword': { buckets: [ { key: 'win xp', - doc_count: 16, + doc_count: 1, }, ], }, }); expect(properties).toEqual({ + doc_count: 3, 'terms_of_machine.os.keyword': 'win xp', + 'terms_of_machine.os.keyword__percentage': 33, + }); + }); + + test('Should handle empty top bucket aggregation', () => { + const properties = extractPropertiesFromBucket({ + doc_count: 3, + 'terms_of_machine.os.keyword': { + buckets: [], + }, + }); + expect(properties).toEqual({ + doc_count: 3, }); }); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts index 7af176acfaf467..9d4f24f80d6cd1 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts +++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/public'; +import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; export function getField(indexPattern: IndexPattern, fieldName: string) { const field = indexPattern.fields.getByName(fieldName); @@ -42,7 +43,19 @@ export function extractPropertiesFromBucket(bucket: any, ignoreKeys: string[] = if (_.has(bucket[key], 'value')) { properties[key] = bucket[key].value; } else if (_.has(bucket[key], 'buckets')) { + if (bucket[key].buckets.length === 0) { + // No top term + continue; + } + properties[key] = _.get(bucket[key], 'buckets[0].key'); + const topBucketCount = bucket[key].buckets[0].doc_count; + const totalCount = bucket.doc_count; + if (totalCount && topBucketCount) { + properties[`${key}${TOP_TERM_PERCENTAGE_SUFFIX}`] = Math.round( + (topBucketCount / totalCount) * 100 + ); + } } else { properties[key] = bucket[key]; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts similarity index 85% rename from x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js rename to x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts index 69ccb8890d10cc..37916e53d6c45e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts @@ -6,6 +6,6 @@ import { AGG_TYPE } from '../../../common/constants'; -export function isMetricCountable(aggType) { +export function isMetricCountable(aggType: AGG_TYPE): boolean { return [AGG_TYPE.COUNT, AGG_TYPE.SUM, AGG_TYPE.UNIQUE_COUNT].includes(aggType); } diff --git a/x-pack/legacy/plugins/ml/public/application/app.tsx b/x-pack/legacy/plugins/ml/public/application/app.tsx index 4c956bfabecc91..18545f31f03c73 100644 --- a/x-pack/legacy/plugins/ml/public/application/app.tsx +++ b/x-pack/legacy/plugins/ml/public/application/app.tsx @@ -25,9 +25,6 @@ export interface MlDependencies extends AppMountParameters { data: DataPublicPluginStart; security: SecurityPluginSetup; licensing: LicensingPluginSetup; - __LEGACY: { - XSRF: string; - }; } interface AppProps { @@ -49,7 +46,6 @@ const App: FC<AppProps> = ({ coreStart, deps }) => { recentlyAccessed: coreStart.chrome!.recentlyAccessed, basePath: coreStart.http.basePath, savedObjectsClient: coreStart.savedObjects.client, - XSRF: deps.__LEGACY.XSRF, application: coreStart.application, http: coreStart.http, security: deps.security, diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js index 23a40d9ecf295a..0c6c959927140a 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js @@ -281,7 +281,7 @@ export function getColumns( defaultMessage: 'actions', }), render: item => { - if (showLinksMenuForItem(item) === true) { + if (showLinksMenuForItem(item, showViewSeriesLink) === true) { return ( <LinksMenu anomaly={item} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js index 89589c98b52c2c..32b5634b143dbf 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js +++ b/x-pack/legacy/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js @@ -39,8 +39,7 @@ function randomNumber(min, max) { } function saveWatch(watchModel) { - const basePath = getBasePath(); - const path = basePath.prepend('/api/watcher'); + const path = '/api/watcher'; const url = `${path}/watch/${watchModel.id}`; return http({ @@ -188,8 +187,7 @@ class CreateWatchService { loadWatch(jobId) { const id = `ml-${jobId}`; - const basePath = getBasePath(); - const path = basePath.prepend('/api/watcher'); + const path = '/api/watcher'; const url = `${path}/watch/${id}`; return http({ url, diff --git a/x-pack/legacy/plugins/ml/public/application/management/index.ts b/x-pack/legacy/plugins/ml/public/application/management/index.ts index a6d1bbfcee9f6f..99a2e8353a8742 100644 --- a/x-pack/legacy/plugins/ml/public/application/management/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/management/index.ts @@ -54,7 +54,6 @@ function initManagementSection() { setDependencyCache({ docLinks: legacyDocLinks as any, basePath: legacyBasePath as any, - XSRF: chrome.getXsrfToken(), }); management.register('ml', { diff --git a/x-pack/legacy/plugins/ml/public/application/services/http_service.ts b/x-pack/legacy/plugins/ml/public/application/services/http_service.ts index 73a30dbcd71b2d..75db2470d77ccf 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/http_service.ts @@ -4,68 +4,66 @@ * you may not use this file except in compliance with the Elastic License. */ -// service for interacting with the server +import { Observable } from 'rxjs'; -import { fromFetch } from 'rxjs/fetch'; -import { from, Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; - -import { getXSRF } from '../util/dependency_cache'; - -export interface HttpOptions { - url?: string; -} +import { getHttp } from '../util/dependency_cache'; function getResultHeaders(headers: HeadersInit): HeadersInit { return { - asSystemRequest: false, + asSystemRequest: true, 'Content-Type': 'application/json', - 'kbn-version': getXSRF(), ...headers, } as HeadersInit; } -export function http(options: any) { - return new Promise((resolve, reject) => { - if (options && options.url) { - let url = ''; - url = url + (options.url || ''); - const headers = getResultHeaders(options.headers ?? {}); - - const allHeaders = - options.headers === undefined ? headers : { ...options.headers, ...headers }; - const body = options.data === undefined ? null : JSON.stringify(options.data); - - const payload: RequestInit = { - method: options.method || 'GET', - headers: allHeaders, - credentials: 'same-origin', - }; - - if (body !== null) { - payload.body = body; - } +interface HttpOptions { + url: string; + method: string; + headers?: any; + data?: any; +} - fetch(url, payload) - .then(resp => { - resp - .json() - .then(resp.ok === true ? resolve : reject) - .catch(resp.ok === true ? resolve : reject); - }) - .catch(resp => { - reject(resp); - }); - } else { - reject(); +/** + * Function for making HTTP requests to Kibana's backend. + * Wrapper for Kibana's HttpHandler. + */ +export async function http(options: HttpOptions) { + if (!options?.url) { + throw new Error('URL is missing'); + } + + try { + let url = ''; + url = url + (options.url || ''); + const headers = getResultHeaders(options.headers ?? {}); + + const allHeaders = options.headers === undefined ? headers : { ...options.headers, ...headers }; + const body = options.data === undefined ? null : JSON.stringify(options.data); + + const payload: RequestInit = { + method: options.method || 'GET', + headers: allHeaders, + credentials: 'same-origin', + }; + + if (body !== null) { + payload.body = body; } - }); + + return await getHttp().fetch(url, payload); + } catch (e) { + throw new Error(e); + } } interface RequestOptions extends RequestInit { body: BodyInit | any; } +/** + * Function for making HTTP requests to Kibana's backend which returns an Observable + * with request cancellation support. + */ export function http$<T>(url: string, options: RequestOptions): Observable<T> { const requestInit: RequestInit = { ...options, @@ -75,13 +73,56 @@ export function http$<T>(url: string, options: RequestOptions): Observable<T> { headers: getResultHeaders(options.headers ?? {}), }; - return fromFetch(url, requestInit).pipe( - switchMap(response => { - if (response.ok) { - return from(response.json() as Promise<T>); + return fromHttpHandler<T>(url, requestInit); +} + +/** + * Creates an Observable from Kibana's HttpHandler. + */ +export function fromHttpHandler<T>(input: string, init?: RequestInit): Observable<T> { + return new Observable<T>(subscriber => { + const controller = new AbortController(); + const signal = controller.signal; + + let abortable = true; + let unsubscribed = false; + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort(); } else { - throw new Error(String(response.status)); + init.signal.addEventListener('abort', () => { + if (!signal.aborted) { + controller.abort(); + } + }); } - }) - ); + } + + const perSubscriberInit: RequestInit = { + ...(init ? init : {}), + signal, + }; + + getHttp() + .fetch<T>(input, perSubscriberInit) + .then(response => { + abortable = false; + subscriber.next(response); + subscriber.complete(); + }) + .catch(err => { + abortable = false; + if (!unsubscribed) { + subscriber.error(err); + } + }); + + return () => { + unsubscribed = true; + if (abortable) { + controller.abort(); + } + }; + }); } diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js index 6fdc76d7244d3c..688abd1383ecb4 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js @@ -13,10 +13,9 @@ import { filters } from './filters'; import { results } from './results'; import { jobs } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; -import { getBasePath } from '../../util/dependency_cache'; export function basePath() { - return getBasePath().prepend('/api/ml'); + return '/api/ml'; } export const ml = { @@ -452,7 +451,7 @@ export const ml = { }, getIndices() { - const tempBasePath = getBasePath().prepend('/api'); + const tempBasePath = '/api'; return http({ url: `${tempBasePath}/index_management/indices`, method: 'GET', diff --git a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts index c167d7e7c3d42b..2a1ffe79d033cd 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts @@ -35,7 +35,6 @@ export interface DependencyCache { autocomplete: DataPublicPluginStart['autocomplete'] | null; basePath: IBasePath | null; savedObjectsClient: SavedObjectsClientContract | null; - XSRF: string | null; application: ApplicationStart | null; http: HttpStart | null; security: SecurityPluginSetup | null; @@ -54,7 +53,6 @@ const cache: DependencyCache = { autocomplete: null, basePath: null, savedObjectsClient: null, - XSRF: null, application: null, http: null, security: null, @@ -73,7 +71,6 @@ export function setDependencyCache(deps: Partial<DependencyCache>) { cache.autocomplete = deps.autocomplete || null; cache.basePath = deps.basePath || null; cache.savedObjectsClient = deps.savedObjectsClient || null; - cache.XSRF = deps.XSRF || null; cache.application = deps.application || null; cache.http = deps.http || null; cache.security = deps.security || null; @@ -162,13 +159,6 @@ export function getSavedObjectsClient() { return cache.savedObjectsClient; } -export function getXSRF() { - if (cache.XSRF === null) { - throw new Error("xsrf hasn't been initialized"); - } - return cache.XSRF; -} - export function getApplication() { if (cache.application === null) { throw new Error("application hasn't been initialized"); diff --git a/x-pack/legacy/plugins/ml/public/legacy.ts b/x-pack/legacy/plugins/ml/public/legacy.ts index 0c6c0bd8dd29e2..9fb53e78d9454a 100644 --- a/x-pack/legacy/plugins/ml/public/legacy.ts +++ b/x-pack/legacy/plugins/ml/public/legacy.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; import { npSetup, npStart } from 'ui/new_platform'; import { PluginInitializerContext } from 'src/core/public'; import { SecurityPluginSetup } from '../../../../plugins/security/public'; @@ -26,8 +25,5 @@ export const setup = pluginInstance.setup(npSetup.core, { data: npStart.plugins.data, security: setupDependencies.security, licensing: setupDependencies.licensing, - __LEGACY: { - XSRF: chrome.getXsrfToken(), - }, }); export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/x-pack/legacy/plugins/ml/public/plugin.ts b/x-pack/legacy/plugins/ml/public/plugin.ts index c0369a74c070ac..7b3a5f6fadfaca 100644 --- a/x-pack/legacy/plugins/ml/public/plugin.ts +++ b/x-pack/legacy/plugins/ml/public/plugin.ts @@ -8,7 +8,7 @@ import { Plugin, CoreStart, CoreSetup } from 'src/core/public'; import { MlDependencies } from './application/app'; export class MlPlugin implements Plugin<Setup, Start> { - setup(core: CoreSetup, { data, security, licensing, __LEGACY }: MlDependencies) { + setup(core: CoreSetup, { data, security, licensing }: MlDependencies) { core.application.register({ id: 'ml', title: 'Machine learning', @@ -21,7 +21,6 @@ export class MlPlugin implements Plugin<Setup, Start> { onAppLeave: params.onAppLeave, history: params.history, data, - __LEGACY, security, licensing, }); diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap index 469f5e6e7b3c6e..757677f1d4f826 100644 --- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap +++ b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap @@ -47,6 +47,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -138,6 +143,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -228,6 +238,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, @@ -319,6 +334,11 @@ Object { }, "settleTime": 1000, "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, "viewport": Object { "height": 1200, "width": 1950, diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts index 34fc1f452fbc06..211fa70301bbf0 100644 --- a/x-pack/legacy/plugins/reporting/config.ts +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -31,6 +31,17 @@ export async function config(Joi: any) { .default(120000), }).default(), capture: Joi.object({ + timeouts: Joi.object({ + openUrl: Joi.number() + .integer() + .default(30000), + waitForElements: Joi.number() + .integer() + .default(30000), + renderComplete: Joi.number() + .integer() + .default(30000), + }).default(), networkPolicy: Joi.object({ enabled: Joi.boolean().default(true), rules: Joi.array() diff --git a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts index 02a3e787da750b..254cfbaa878bdf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts @@ -9,4 +9,4 @@ export const LayoutTypes = { PRINT: 'print', }; -export const WAITFOR_SELECTOR = '.application'; +export const PAGELOAD_SELECTOR = '.application'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts index 54fae60a0773c0..2c43517dbcaa91 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts @@ -27,7 +27,6 @@ export interface LayoutSelectorDictionary { renderComplete: string; itemsCountAttribute: string; timefilterDurationAttribute: string; - toastHeader: string; } export interface PdfImageSize { @@ -40,7 +39,6 @@ export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({ renderComplete: '[data-shared-item]', itemsCountAttribute: 'data-shared-items-count', timefilterDurationAttribute: 'data-shared-timefilter-duration', - toastHeader: '[data-test-subj="euiToastHeader"]', }); export abstract class Layout { @@ -75,9 +73,11 @@ export interface LayoutParams { dimensions: Size; } -export type LayoutInstance = Layout & { +interface LayoutSelectors { // Fields that are not part of Layout: the instances // independently implement these fields on their own selectors: LayoutSelectorDictionary; positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise<void>; -}; +} + +export type LayoutInstance = Layout & LayoutSelectors & Size; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts index cfa421b6f66abf..07dbba7d25883c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts @@ -19,8 +19,8 @@ const ZOOM: number = 2; export class PreserveLayout extends Layout { public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); public readonly groupCount = 1; - private readonly height: number; - private readonly width: number; + public readonly height: number; + public readonly width: number; private readonly scaledHeight: number; private readonly scaledWidth: number; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts deleted file mode 100644 index c888870bd2bc35..00000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { ElementHandle } from 'puppeteer'; -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_CHECKFORTOASTMESSAGE } from './constants'; - -export const checkForToastMessage = async ( - browser: HeadlessBrowser, - layout: LayoutInstance, - logger: LevelLogger -): Promise<ElementHandle<Element>> => { - return await browser - .waitForSelector(layout.selectors.toastHeader, { silent: true }, logger) - .then(async () => { - // Check for a toast message on the page. If there is one, capture the - // message and throw an error, to fail the screenshot. - const toastHeaderText: string = await browser.evaluate( - { - fn: selector => { - const nodeList = document.querySelectorAll(selector); - return nodeList.item(0).innerText; - }, - args: [layout.selectors.toastHeader], - }, - { context: CONTEXT_CHECKFORTOASTMESSAGE }, - logger - ); - - // Log an error to track the event in kibana server logs - logger.error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage', - { - defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}', - values: { toastHeaderText }, - } - ) - ); - - // Throw an error to fail the screenshot job with a message - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage', - { - defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}', - values: { toastHeaderText }, - } - ) - ); - }); -}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts index bbc97ca57940c9..a3faf9337524ee 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts @@ -9,6 +9,6 @@ export const CONTEXT_INJECTCSS = 'InjectCss'; export const CONTEXT_WAITFORRENDER = 'WaitForRender'; export const CONTEXT_GETTIMERANGE = 'GetTimeRange'; export const CONTEXT_ELEMENTATTRIBUTES = 'ElementPositionAndAttributes'; -export const CONTEXT_CHECKFORTOASTMESSAGE = 'CheckForToastMessage'; export const CONTEXT_WAITFORELEMENTSTOBEINDOM = 'WaitForElementsToBeInDOM'; export const CONTEXT_SKIPTELEMETRY = 'SkipTelemetry'; +export const CONTEXT_READMETADATA = 'ReadVisualizationsMetadata'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts index 4302f4c631e3cd..2f93765165e50d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LayoutInstance } from '../../layouts/layout'; import { AttributesMap, ElementsPositionAndAttribute } from './types'; @@ -14,50 +15,58 @@ export const getElementPositionAndAttributes = async ( browser: HeadlessBrowser, layout: LayoutInstance, logger: Logger -): Promise<ElementsPositionAndAttribute[]> => { - const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate( - { - fn: (selector: string, attributes: any) => { - const elements: NodeListOf<Element> = document.querySelectorAll(selector); +): Promise<ElementsPositionAndAttribute[] | null> => { + const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container + let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + try { + elementsPositionAndAttributes = await browser.evaluate( + { + fn: (selector, attributes) => { + const elements: NodeListOf<Element> = document.querySelectorAll(selector); - // NodeList isn't an array, just an iterator, unable to use .map/.forEach - const results: ElementsPositionAndAttribute[] = []; - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - const boundingClientRect = element.getBoundingClientRect() as DOMRect; - results.push({ - position: { - boundingClientRect: { - // modern browsers support x/y, but older ones don't - top: boundingClientRect.y || boundingClientRect.top, - left: boundingClientRect.x || boundingClientRect.left, - width: boundingClientRect.width, - height: boundingClientRect.height, + // NodeList isn't an array, just an iterator, unable to use .map/.forEach + const results: ElementsPositionAndAttribute[] = []; + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const boundingClientRect = element.getBoundingClientRect() as DOMRect; + results.push({ + position: { + boundingClientRect: { + // modern browsers support x/y, but older ones don't + top: boundingClientRect.y || boundingClientRect.top, + left: boundingClientRect.x || boundingClientRect.left, + width: boundingClientRect.width, + height: boundingClientRect.height, + }, + scroll: { + x: window.scrollX, + y: window.scrollY, + }, }, - scroll: { - x: window.scrollX, - y: window.scrollY, - }, - }, - attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => { - const attribute = attributes[key]; - (result as any)[key] = element.getAttribute(attribute); - return result; - }, {} as AttributesMap), - }); - } - return results; + attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => { + const attribute = attributes[key]; + (result as any)[key] = element.getAttribute(attribute); + return result; + }, {} as AttributesMap), + }); + } + return results; + }, + args: [screenshotSelector, { title: 'data-title', description: 'data-description' }], }, - args: [layout.selectors.screenshot, { title: 'data-title', description: 'data-description' }], - }, - { context: CONTEXT_ELEMENTATTRIBUTES }, - logger - ); - - if (elementsPositionAndAttributes.length === 0) { - throw new Error( - `No shared items containers were found on the page! Reporting requires a container element with the '${layout.selectors.screenshot}' attribute on the page.` + { context: CONTEXT_ELEMENTATTRIBUTES }, + logger ); + + if (!elementsPositionAndAttributes || elementsPositionAndAttributes.length === 0) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.noElements', { + defaultMessage: `An error occurred while reading the page for visualization panels: no panels were found.`, + }) + ); + } + } catch (err) { + elementsPositionAndAttributes = null; } return elementsPositionAndAttributes; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 1beae719cd6b00..16eb433e8a75e3 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -4,38 +4,72 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { ServerFacade } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_GETNUMBEROFITEMS } from './constants'; +import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( + server: ServerFacade, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise<number> => { - logger.debug('determining how many rendered items to wait for'); + const config = server.config(); + const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; + let itemsCount: number; - // returns the value of the `itemsCountAttribute` if it's there, otherwise - // we just count the number of `itemSelector` - const itemsCount: number = await browser.evaluate( - { - fn: (selector, countAttribute) => { - const elementWithCount = document.querySelector(`[${countAttribute}]`); - if (elementWithCount && elementWithCount != null) { - const count = elementWithCount.getAttribute(countAttribute); - if (count && count != null) { - return parseInt(count, 10); + logger.debug( + i18n.translate('xpack.reporting.screencapture.logWaitingForElements', { + defaultMessage: 'waiting for elements or items count attribute; or not found to interrupt', + }) + ); + + try { + // the dashboard is using the `itemsCountAttribute` attribute to let us + // know how many items to expect since gridster incrementally adds panels + // we have to use this hint to wait for all of them + await browser.waitForSelector( + `${renderCompleteSelector},[${itemsCountAttribute}]`, + { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, + { context: CONTEXT_READMETADATA }, + logger + ); + + // returns the value of the `itemsCountAttribute` if it's there, otherwise + // we just count the number of `itemSelector`: the number of items already rendered + itemsCount = await browser.evaluate( + { + fn: (selector, countAttribute) => { + const elementWithCount = document.querySelector(`[${countAttribute}]`); + if (elementWithCount && elementWithCount != null) { + const count = elementWithCount.getAttribute(countAttribute); + if (count && count != null) { + return parseInt(count, 10); + } } - } - return document.querySelectorAll(selector).length; + return document.querySelectorAll(selector).length; + }, + args: [renderCompleteSelector, itemsCountAttribute], }, - args: [layout.selectors.renderComplete, layout.selectors.itemsCountAttribute], - }, - { context: CONTEXT_GETNUMBEROFITEMS }, - logger - ); + { context: CONTEXT_GETNUMBEROFITEMS }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { + defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, + values: { + error: err, + configKey: 'xpack.reporting.capture.timeouts.waitForElements', + }, + }) + ); + itemsCount = 1; + } return itemsCount; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts index b21d1e752ba3f5..d50ac64743f078 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; import { Screenshot, ElementsPositionAndAttribute } from './types'; @@ -12,21 +13,29 @@ const getAsyncDurationLogger = (logger: LevelLogger) => { return async (description: string, promise: Promise<any>) => { const start = Date.now(); const result = await promise; - logger.debug(`${description} took ${Date.now() - start}ms`); + logger.debug( + i18n.translate('xpack.reporting.screencapture.asyncTook', { + defaultMessage: '{description} took {took}ms', + values: { + description, + took: Date.now() - start, + }, + }) + ); return result; }; }; -export const getScreenshots = async ({ - browser, - elementsPositionAndAttributes, - logger, -}: { - logger: LevelLogger; - browser: HeadlessBrowser; - elementsPositionAndAttributes: ElementsPositionAndAttribute[]; -}): Promise<Screenshot[]> => { - logger.info(`taking screenshots`); +export const getScreenshots = async ( + browser: HeadlessBrowser, + elementsPositionAndAttributes: ElementsPositionAndAttribute[], + logger: LevelLogger +): Promise<Screenshot[]> => { + logger.info( + i18n.translate('xpack.reporting.screencapture.takingScreenshots', { + defaultMessage: `taking screenshots`, + }) + ); const asyncDurationLogger = getAsyncDurationLogger(logger); const screenshots: Screenshot[] = []; @@ -45,7 +54,14 @@ export const getScreenshots = async ({ }); } - logger.info(`screenshots taken: ${screenshots.length}`); + logger.info( + i18n.translate('xpack.reporting.screencapture.screenshotsTaken', { + defaultMessage: `screenshots taken: {numScreenhots}`, + values: { + numScreenhots: screenshots.length, + }, + }) + ); return screenshots; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts index 40204804a276f6..cb2673e85186ba 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import fs from 'fs'; import { promisify } from 'util'; import { LevelLogger } from '../../../../server/lib'; @@ -18,21 +19,34 @@ export const injectCustomCss = async ( layout: Layout, logger: LevelLogger ): Promise<void> => { - logger.debug('injecting custom css'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.injectingCss', { + defaultMessage: 'injecting custom css', + }) + ); const filePath = layout.getCssOverridesPath(); const buffer = await fsp.readFile(filePath); - await browser.evaluate( - { - fn: css => { - const node = document.createElement('style'); - node.type = 'text/css'; - node.innerHTML = css; // eslint-disable-line no-unsanitized/property - document.getElementsByTagName('head')[0].appendChild(node); + try { + await browser.evaluate( + { + fn: css => { + const node = document.createElement('style'); + node.type = 'text/css'; + node.innerHTML = css; // eslint-disable-line no-unsanitized/property + document.getElementsByTagName('head')[0].appendChild(node); + }, + args: [buffer.toString()], }, - args: [buffer.toString()], - }, - { context: CONTEXT_INJECTCSS }, - logger - ); + { context: CONTEXT_INJECTCSS }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.injectCss', { + defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`, + values: { error: err }, + }) + ); + } }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 9f8e218f4f614a..13d07bcdd6baf7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -23,7 +23,6 @@ import { createMockBrowserDriverFactory, createMockLayoutInstance, createMockServer, - mockSelectors, } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; import { screenshotsObservableFactory } from './observable'; @@ -61,6 +60,7 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", @@ -98,6 +98,7 @@ describe('Screenshot Observable Pipeline', () => { expect(result).toMatchInlineSnapshot(` Array [ Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 screenshots", @@ -108,6 +109,7 @@ describe('Screenshot Observable Pipeline', () => { "timeRange": "Default GetTimeRange Result", }, Object { + "error": undefined, "screenshots": Array [ Object { "base64EncodedData": "allyourBase64 screenshots", @@ -122,15 +124,10 @@ describe('Screenshot Observable Pipeline', () => { }); describe('error handling', () => { - it('fails if error toast message is found', async () => { + it('recovers if waitForSelector fails', async () => { // mock implementations const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => { - const { toastHeader } = mockSelectors; - if (selectorArg === toastHeader) { - return Promise.resolve(true); - } - // make the error toast message get found before anything else - return Rx.interval(100).toPromise(); + throw new Error('Mock error!'); }); // mocks @@ -153,12 +150,35 @@ describe('Screenshot Observable Pipeline', () => { }).toPromise(); }; - await expect(getScreenshot()).rejects.toMatchInlineSnapshot( - `[Error: Encountered an unexpected message on the page: Toast Message]` - ); + await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + Object { + "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + ] + `); }); - it('fails if exit$ fires a timeout or error signal', async () => { + it('recovers if exit$ fires a timeout signal', async () => { // mocks const mockGetCreatePage = (driver: HeadlessChromiumDriver) => jest @@ -188,7 +208,21 @@ describe('Screenshot Observable Pipeline', () => { }).toPromise(); }; - await expect(getScreenshot()).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`); + await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "error": "Instant timeout has fired!", + "screenshots": Array [ + Object { + "base64EncodedData": "allyourBase64 of boundingClientRect,scroll", + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + ] + `); }); }); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index d429931602951b..878a9d3b873932 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -5,19 +5,18 @@ */ import * as Rx from 'rxjs'; -import { concatMap, first, mergeMap, take, toArray } from 'rxjs/operators'; +import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; -import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; -import { scanPage } from './scan_page'; -import { ScreenshotObservableOpts, ScreenshotResults } from './types'; -import { waitForElementsToBeInDOM } from './wait_for_dom_elements'; -import { waitForRenderComplete } from './wait_for_render'; import { skipTelemetry } from './skip_telemetry'; +import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; +import { waitForRenderComplete } from './wait_for_render'; +import { waitForVisualizations } from './wait_for_visualizations'; +import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( server: ServerFacade, @@ -41,16 +40,16 @@ export function screenshotsObservableFactory( concatMap(url => { return create$.pipe( mergeMap(({ driver, exit$ }) => { - const screenshot$ = Rx.of(1).pipe( - mergeMap(() => openUrl(driver, url, conditionalHeaders, logger)), + const setup$: Rx.Observable<ScreenSetupData> = Rx.of(1).pipe( + takeUntil(exit$), + mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), mergeMap(() => skipTelemetry(driver, logger)), - mergeMap(() => scanPage(driver, layout, logger)), - mergeMap(() => getNumberOfItems(driver, layout, logger)), + mergeMap(() => getNumberOfItems(server, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForElementsToBeInDOM(driver, itemsCount, layout, logger), + waitForVisualizations(server, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -63,28 +62,35 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(captureConfig, driver, layout, logger); + await waitForRenderComplete(driver, layout, captureConfig, logger); }), - mergeMap(() => getTimeRange(driver, layout, logger)), - mergeMap( - async (timeRange): Promise<ScreenshotResults> => { - const elementsPositionAndAttributes = await getElementPositionAndAttributes( - driver, - layout, - logger - ); - const screenshots = await getScreenshots({ - browser: driver, - elementsPositionAndAttributes, - logger, - }); + mergeMap(async () => { + return await Promise.all([ + getTimeRange(driver, layout, logger), + getElementPositionAndAttributes(driver, layout, logger), + ]).then(([timeRange, elementsPositionAndAttributes]) => ({ + elementsPositionAndAttributes, + timeRange, + })); + }), + catchError(err => { + logger.error(err); + return Rx.of({ elementsPositionAndAttributes: null, timeRange: null, error: err }); + }) + ); - return { timeRange, screenshots }; + return setup$.pipe( + mergeMap( + async (data: ScreenSetupData): Promise<ScreenshotResults> => { + const elements = data.elementsPositionAndAttributes + ? data.elementsPositionAndAttributes + : getDefaultElementPosition(layout.getViewport(1)); + const screenshots = await getScreenshots(driver, elements, logger); + const { timeRange, error: setupError } = data; + return { timeRange, screenshots, error: setupError }; } ) ); - - return Rx.race(screenshot$, exit$); }), first() ); @@ -94,3 +100,18 @@ export function screenshotsObservableFactory( ); }; } + +/* + * If an error happens setting up the page, we don't know if there actually + * are any visualizations showing. These defaults should help capture the page + * enough for the user to see the error themselves + */ +const getDefaultElementPosition = ({ height, width }: { height: number; width: number }) => [ + { + position: { + boundingClientRect: { top: 0, left: 0, height, width }, + scroll: { x: 0, y: 0 }, + }, + attributes: {}, + }, +]; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index e465499f839f91..fbae1f91a7a6a7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -4,23 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders } from '../../../../types'; +import { i18n } from '@kbn/i18n'; +import { ConditionalHeaders, ServerFacade } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { WAITFOR_SELECTOR } from '../../constants'; +import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( + server: ServerFacade, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise<void> => { - await browser.open( - url, - { - conditionalHeaders, - waitForSelector: WAITFOR_SELECTOR, - }, - logger - ); + const config = server.config(); + + try { + await browser.open( + url, + { + conditionalHeaders, + waitForSelector: PAGELOAD_SELECTOR, + timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), + }, + logger + ); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { + defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, + values: { + configKey: 'xpack.reporting.capture.timeouts.openUrl', + error: err, + }, + }) + ); + } }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts deleted file mode 100644 index 010ffe8f23afce..00000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Rx from 'rxjs'; -import { HeadlessChromiumDriver } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { checkForToastMessage } from './check_for_toast'; - -export function scanPage( - browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger -) { - logger.debug('waiting for elements or items count attribute; or not found to interrupt'); - - // the dashboard is using the `itemsCountAttribute` attribute to let us - // know how many items to expect since gridster incrementally adds panels - // we have to use this hint to wait for all of them - const renderSuccess = browser.waitForSelector( - `${layout.selectors.renderComplete},[${layout.selectors.itemsCountAttribute}]`, - {}, - logger - ); - const renderError = checkForToastMessage(browser, layout, logger); - return Rx.race(Rx.from(renderSuccess), Rx.from(renderError)); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index 78cd42f0cae2fd..ab81a952f345ce 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -35,7 +35,14 @@ export interface Screenshot { description: string; } +export interface ScreenSetupData { + elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + timeRange: TimeRange | null; + error?: Error; +} + export interface ScreenshotResults { timeRange: TimeRange | null; screenshots: Screenshot[]; + error?: Error; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts deleted file mode 100644 index c958585f78e0df..00000000000000 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; -import { LayoutInstance } from '../../layouts/layout'; -import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; - -export const waitForElementsToBeInDOM = async ( - browser: HeadlessBrowser, - itemsCount: number, - layout: LayoutInstance, - logger: LevelLogger -): Promise<number> => { - logger.debug(`waiting for ${itemsCount} rendered elements to be in the DOM`); - - await browser.waitFor( - { - fn: selector => { - return document.querySelectorAll(selector).length; - }, - args: [layout.selectors.renderComplete], - toEqual: itemsCount, - }, - { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, - logger - ); - - logger.info(`found ${itemsCount} rendered elements in the DOM`); - return itemsCount; -}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 632f008ca63bcc..2f6dc2829dfd8d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; @@ -11,12 +12,16 @@ import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, + captureConfig: CaptureConfig, logger: LevelLogger ) => { - logger.debug('waiting for rendering to complete'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.waitingForRenderComplete', { + defaultMessage: 'waiting for rendering to complete', + }) + ); return await browser .evaluate( @@ -66,6 +71,10 @@ export const waitForRenderComplete = async ( logger ) .then(() => { - logger.debug('rendering is complete'); + logger.debug( + i18n.translate('xpack.reporting.screencapture.renderIsComplete', { + defaultMessage: 'rendering is complete', + }) + ); }); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts new file mode 100644 index 00000000000000..93ad40026dff81 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ServerFacade } from '../../../../types'; +import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger } from '../../../../server/lib'; +import { LayoutInstance } from '../../layouts/layout'; +import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; + +type SelectorArgs = Record<string, string>; + +const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { + return document.querySelectorAll(renderCompleteSelector).length; +}; + +/* + * 1. Wait for the visualization metadata to be found in the DOM + * 2. Read the metadata for the number of visualization items + * 3. Wait for the render complete event to be fired once for each item + */ +export const waitForVisualizations = async ( + server: ServerFacade, + browser: HeadlessBrowser, + itemsCount: number, + layout: LayoutInstance, + logger: LevelLogger +): Promise<void> => { + const config = server.config(); + const { renderComplete: renderCompleteSelector } = layout.selectors; + + logger.debug( + i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', { + defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`, + values: { itemsCount }, + }) + ); + + try { + await browser.waitFor( + { + fn: getCompletedItemsCount, + args: [{ renderCompleteSelector }], + toEqual: itemsCount, + timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), + }, + { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, + logger + ); + + logger.debug(`found ${itemsCount} rendered elements in the DOM`); + } catch (err) { + throw new Error( + i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { + defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, + values: { + count: itemsCount, + configKey: 'xpack.reporting.capture.timeouts.renderComplete', + error: err, + }, + }) + ); + } +}; diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index c0c21119e1d53c..e2e6ba1b890963 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -114,7 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const testContent = 'test content'; const generatePngObservable = generatePngObservableFactory(); - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(testContent))); + generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const executeJob = await executeJobFactory( mockReporting, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 5cde2450809149..8670f0027af89e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,17 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; -import { ReportingCore } from '../../../../server'; import { PNG_JOB_TYPE } from '../../../../common/constants'; -import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types'; +import { ReportingCore } from '../../../../server'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, - omitBlacklistedHeaders, getConditionalHeaders, getFullUrls, + omitBlacklistedHeaders, } from '../../../common/execute_job/'; import { JobDocPayloadPNG } from '../../types'; import { generatePngObservableFactory } from '../lib/generate_png'; @@ -33,7 +39,7 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut return function executeJob(jobId: string, job: JobDocPayloadPNG, cancellationToken: any) { const jobLogger = logger.clone([jobId]); - const process$ = Rx.of(1).pipe( + const process$: Rx.Observable<JobDocOutput> = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), @@ -48,11 +54,12 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut job.layout ); }), - map((buffer: Buffer) => { + map(({ buffer, warnings }) => { return { content_type: 'image/png', content: buffer.toString('base64'), size: buffer.byteLength, + warnings, }; }), catchError(err => { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index 600762c451a791..88e91982adc632 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,10 +7,11 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types'; -import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; -import { PreserveLayout } from '../../../common/layouts/preserve_layout'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; +import { PreserveLayout } from '../../../common/layouts/preserve_layout'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( server: ServerFacade, @@ -24,7 +25,7 @@ export function generatePngObservableFactory( browserTimezone: string, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams - ): Rx.Observable<Buffer> { + ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { if (!layoutParams || !layoutParams.dimensions) { throw new Error(`LayoutParams.Dimensions is undefined.`); } @@ -37,12 +38,16 @@ export function generatePngObservableFactory( layout, browserTimezone, }).pipe( - map(([{ screenshots }]) => { - if (screenshots.length !== 1) { - throw new Error(`Expected there to be 1 screenshot, but there are ${screenshots.length}`); - } - - return screenshots[0].base64EncodedData; + map((results: ScreenshotResults[]) => { + return { + buffer: results[0].screenshots[0].base64EncodedData, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + return found; + }, [] as string[]), + }; }) ); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index cc6b298bebdc54..484842ba18f2ad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -82,7 +82,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const testContent = 'test content'; const generatePdfObservable = generatePdfObservableFactory(); - generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(testContent))); + generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const executeJob = await executeJobFactory( mockReporting, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index e8461862bee823..535c2dcd439a7a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,21 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; -import { ReportingCore } from '../../../../server'; -import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types'; -import { JobDocPayloadPDF } from '../../types'; import { PDF_JOB_TYPE } from '../../../../common/constants'; -import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { ReportingCore } from '../../../../server'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, - omitBlacklistedHeaders, getConditionalHeaders, - getFullUrls, getCustomLogo, + getFullUrls, + omitBlacklistedHeaders, } from '../../../common/execute_job/'; +import { JobDocPayloadPDF } from '../../types'; +import { generatePdfObservableFactory } from '../lib/generate_pdf'; type QueuedPdfExecutorFactory = ExecuteJobFactory<ESQueueWorkerExecuteFn<JobDocPayloadPDF>>; @@ -34,8 +40,7 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut return function executeJob(jobId: string, job: JobDocPayloadPDF, cancellationToken: any) { const jobLogger = logger.clone([jobId]); - - const process$ = Rx.of(1).pipe( + const process$: Rx.Observable<JobDocOutput> = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), @@ -54,10 +59,11 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut logo ); }), - map((buffer: Buffer) => ({ + map(({ buffer, warnings }) => ({ content_type: 'application/pdf', content: buffer.toString('base64'), size: buffer.byteLength, + warnings, })), catchError(err => { jobLogger.error(err); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index 9a8db308bea794..d78effaa1fc2f9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { groupBy } from 'lodash'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types'; -// @ts-ignore untyped module -import { pdf } from './pdf'; -import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { createLayout } from '../../../common/layouts'; -import { ScreenshotResults } from '../../../common/lib/screenshots/types'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { ScreenshotResults } from '../../../common/lib/screenshots/types'; +// @ts-ignore untyped module +import { pdf } from './pdf'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { const grouped = groupBy(urlScreenshots.map(u => u.timeRange)); @@ -40,7 +40,7 @@ export function generatePdfObservableFactory( conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams, logo?: string - ): Rx.Observable<Buffer> { + ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { const layout = createLayout(server, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, @@ -49,17 +49,17 @@ export function generatePdfObservableFactory( layout, browserTimezone, }).pipe( - mergeMap(async urlScreenshots => { + mergeMap(async (results: ScreenshotResults[]) => { const pdfOutput = pdf.create(layout, logo); if (title) { - const timeRange = getTimeRange(urlScreenshots); + const timeRange = getTimeRange(results); title += timeRange ? ` - ${timeRange.duration}` : ''; pdfOutput.setTitle(title); } - urlScreenshots.forEach(({ screenshots }) => { - screenshots.forEach(screenshot => { + results.forEach(r => { + r.screenshots.forEach(screenshot => { pdfOutput.addImage(screenshot.base64EncodedData, { title: screenshot.title, description: screenshot.description, @@ -68,7 +68,16 @@ export function generatePdfObservableFactory( }); pdfOutput.generate(); - return await pdfOutput.getBuffer(); + + return { + buffer: await pdfOutput.getBuffer(), + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + return found; + }, [] as string[]), + }; }) ); diff --git a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx index 77869c40d35778..7f5d070948e50a 100644 --- a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx +++ b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx @@ -86,95 +86,102 @@ export class ReportInfoButton extends Component<Props, State> { const maxAttempts = info.max_attempts ? info.max_attempts.toString() : NA; const priority = info.priority ? info.priority.toString() : NA; const timeout = info.timeout ? info.timeout.toString() : NA; + const warnings = info.output && info.output.warnings ? info.output.warnings.join(',') : null; + + const jobInfoDateTimes: JobInfo[] = [ + { + title: 'Created By', + description: info.created_by || NA, + }, + { + title: 'Created At', + description: info.created_at || NA, + }, + { + title: 'Started At', + description: info.started_at || NA, + }, + { + title: 'Completed At', + description: info.completed_at || NA, + }, + { + title: 'Processed By', + description: + info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : UNKNOWN, + }, + { + title: 'Browser Timezone', + description: get(info, 'payload.browserTimezone') || NA, + }, + ]; + const jobInfoPayload: JobInfo[] = [ + { + title: 'Title', + description: get(info, 'payload.title') || NA, + }, + { + title: 'Type', + description: get(info, 'payload.type') || NA, + }, + { + title: 'Layout', + description: get(info, 'meta.layout') || NA, + }, + { + title: 'Dimensions', + description: getDimensions(info), + }, + { + title: 'Job Type', + description: jobType, + }, + { + title: 'Content Type', + description: get(info, 'output.content_type') || NA, + }, + { + title: 'Size in Bytes', + description: get(info, 'output.size') || NA, + }, + ]; + const jobInfoStatus: JobInfo[] = [ + { + title: 'Attempts', + description: attempts, + }, + { + title: 'Max Attempts', + description: maxAttempts, + }, + { + title: 'Priority', + description: priority, + }, + { + title: 'Timeout', + description: timeout, + }, + { + title: 'Status', + description: info.status || NA, + }, + { + title: 'Browser Type', + description: USES_HEADLESS_JOB_TYPES.includes(jobType) ? info.browser_type || UNKNOWN : NA, + }, + ]; + if (warnings) { + jobInfoStatus.push({ + title: 'Errors', + description: warnings, + }); + } const jobInfoParts: JobInfoMap = { - datetimes: [ - { - title: 'Created By', - description: info.created_by || NA, - }, - { - title: 'Created At', - description: info.created_at || NA, - }, - { - title: 'Started At', - description: info.started_at || NA, - }, - { - title: 'Completed At', - description: info.completed_at || NA, - }, - { - title: 'Processed By', - description: - info.kibana_name && info.kibana_id - ? `${info.kibana_name} (${info.kibana_id})` - : UNKNOWN, - }, - { - title: 'Browser Timezone', - description: get(info, 'payload.browserTimezone') || NA, - }, - ], - payload: [ - { - title: 'Title', - description: get(info, 'payload.title') || NA, - }, - { - title: 'Type', - description: get(info, 'payload.type') || NA, - }, - { - title: 'Layout', - description: get(info, 'meta.layout') || NA, - }, - { - title: 'Dimensions', - description: getDimensions(info), - }, - { - title: 'Job Type', - description: jobType, - }, - { - title: 'Content Type', - description: get(info, 'output.content_type') || NA, - }, - { - title: 'Size in Bytes', - description: get(info, 'output.size') || NA, - }, - ], - status: [ - { - title: 'Attempts', - description: attempts, - }, - { - title: 'Max Attempts', - description: maxAttempts, - }, - { - title: 'Priority', - description: priority, - }, - { - title: 'Timeout', - description: timeout, - }, - { - title: 'Status', - description: info.status || NA, - }, - { - title: 'Browser Type', - description: USES_HEADLESS_JOB_TYPES.includes(jobType) - ? info.browser_type || UNKNOWN - : NA, - }, - ], + datetimes: jobInfoDateTimes, + payload: jobInfoPayload, + status: jobInfoStatus, }; return ( diff --git a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx index 320f6220aa9968..54061eda94dce2 100644 --- a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx @@ -43,6 +43,7 @@ interface Job { attempts: number; max_attempts: number; csv_contains_formulas: boolean; + warnings: string[]; } interface Props { @@ -203,7 +204,7 @@ class ReportListingUi extends Component<Props, State> { return ( <div> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText" + id="xpack.reporting.listing.tableValue.statusDetail.pendingStatusReachedText" defaultMessage="Pending - waiting for job to be processed" /> </div> @@ -215,13 +216,27 @@ class ReportListingUi extends Component<Props, State> { maxSizeReached = ( <span> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText" + id="xpack.reporting.listing.tableValue.statusDetail.maxSizeReachedText" defaultMessage=" - Max size reached" /> </span> ); } + let warnings; + if (record.warnings) { + warnings = ( + <EuiText size="s"> + <EuiTextColor color="subdued"> + <FormattedMessage + id="xpack.reporting.listing.tableValue.statusDetail.warningsText" + defaultMessage="Errors occurred: see job info for details." + /> + </EuiTextColor> + </EuiText> + ); + } + let statusTimestamp; if (status === JobStatuses.PROCESSING && record.started_at) { statusTimestamp = this.formatDate(record.started_at); @@ -242,7 +257,7 @@ class ReportListingUi extends Component<Props, State> { return ( <div> <FormattedMessage - id="xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" defaultMessage="{statusLabel} at {statusTimestamp}" values={{ statusLabel, @@ -250,6 +265,7 @@ class ReportListingUi extends Component<Props, State> { }} /> {maxSizeReached} + {warnings} </div> ); } @@ -259,6 +275,7 @@ class ReportListingUi extends Component<Props, State> { <div> {statusLabel} {maxSizeReached} + {warnings} </div> ); }, @@ -437,6 +454,7 @@ class ReportListingUi extends Component<Props, State> { attempts: source.attempts, max_attempts: source.max_attempts, csv_contains_formulas: get(source, 'output.csv_contains_formulas'), + warnings: source.output ? source.output.warnings : undefined, }; } ), diff --git a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts index 281a2e1cdf9a51..87d4174168b7f8 100644 --- a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts +++ b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts @@ -31,6 +31,7 @@ export interface JobInfo { output: { content_type: string; size: number; + warnings: string[]; }; process_expiration: string; completed_at: string; diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 0592124b9897b9..60799e3e918b82 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { trunc, map } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { map, trunc } from 'lodash'; import open from 'opn'; +import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; -import { Page, SerializableOrJSHandle, EvaluateFn } from 'puppeteer'; import { ViewZoomWidthHeight } from '../../../../export_types/common/layouts/layout'; import { LevelLogger } from '../../../../server/lib'; -import { allowRequest } from '../../network_policy'; import { ConditionalHeaders, ConditionalHeadersConditions, @@ -18,6 +18,7 @@ import { InterceptedRequest, NetworkPolicy, } from '../../../../types'; +import { allowRequest } from '../../network_policy'; export interface ChromiumDriverOptions { inspect: boolean; @@ -25,7 +26,7 @@ export interface ChromiumDriverOptions { } interface WaitForSelectorOpts { - silent?: boolean; + timeout: number; } interface EvaluateOpts { @@ -65,10 +66,15 @@ export class HeadlessChromiumDriver { url: string, { conditionalHeaders, - waitForSelector, - }: { conditionalHeaders: ConditionalHeaders; waitForSelector: string }, + waitForSelector: pageLoadSelector, + timeout, + }: { + conditionalHeaders: ConditionalHeaders; + waitForSelector: string; + timeout: number; + }, logger: LevelLogger - ) { + ): Promise<void> { logger.info(`opening url ${url}`); // @ts-ignore const client = this.page._client; @@ -81,7 +87,7 @@ export class HeadlessChromiumDriver { // https://github.com/puppeteer/puppeteer/issues/5003 // Docs on this client/protocol can be found here: // https://chromedevtools.github.io/devtools-protocol/tot/Fetch - client.on('Fetch.requestPaused', (interceptedRequest: InterceptedRequest) => { + client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { const { requestId, request: { url: interceptedUrl }, @@ -92,12 +98,17 @@ export class HeadlessChromiumDriver { // We should never ever let file protocol requests go through if (!allowed || !this.allowRequest(interceptedUrl)) { logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); - client.send('Fetch.failRequest', { + await client.send('Fetch.failRequest', { errorReason: 'Aborted', requestId, }); this.page.browser().close(); - throw new Error(`Received disallowed outgoing URL: "${interceptedUrl}", exiting`); + throw new Error( + i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { + defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, + values: { interceptedUrl }, + }) + ); } if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { @@ -112,14 +123,33 @@ export class HeadlessChromiumDriver { value, }) ); - client.send('Fetch.continueRequest', { - requestId, - headers, - }); + + try { + await client.send('Fetch.continueRequest', { + requestId, + headers, + }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { + defaultMessage: 'Failed to complete a request using headers: {error}', + values: { error: err }, + }) + ); + } } else { const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; logger.debug(`No custom headers for ${loggedUrl}`); - client.send('Fetch.continueRequest', { requestId }); + try { + await client.send('Fetch.continueRequest', { requestId }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { + defaultMessage: 'Failed to complete a request: {error}', + values: { error: err }, + }) + ); + } } interceptedCount = interceptedCount + (isData ? 0 : 1); }); @@ -144,11 +174,16 @@ export class HeadlessChromiumDriver { await this.launchDebugger(); } - await this.waitForSelector(waitForSelector, {}, logger); + await this.waitForSelector( + pageLoadSelector, + { timeout }, + { context: 'waiting for page load selector' }, + logger + ); logger.info(`handled ${interceptedCount} page requests`); } - public async screenshot(elementPosition: ElementPosition) { + public async screenshot(elementPosition: ElementPosition): Promise<string> { let clip; if (elementPosition) { const { boundingClientRect, scroll = { x: 0, y: 0 } } = elementPosition; @@ -176,63 +211,56 @@ export class HeadlessChromiumDriver { const result = await this.page.evaluate(fn, ...args); return result; } + public async waitForSelector( selector: string, - opts: WaitForSelectorOpts = {}, + opts: WaitForSelectorOpts, + context: EvaluateMetaOpts, logger: LevelLogger - ) { - const { silent = false } = opts; + ): Promise<ElementHandle<Element>> { + const { timeout } = opts; logger.debug(`waitForSelector ${selector}`); - - let resp; - try { - resp = await this.page.waitFor(selector); - } catch (err) { - if (!silent) { - // Provide some troubleshooting info to see if we're on the login page, - // "Kibana could not load correctly", etc - logger.error(`waitForSelector ${selector} failed on ${this.page.url()}`); - const pageText = await this.evaluate( - { - fn: () => document.querySelector('body')!.innerText, - args: [], - }, - { context: `waitForSelector${selector}` }, - logger - ); - logger.debug(`Page plain text: ${pageText.replace(/\n/g, '\\n')}`); // replace newline with escaped for single log line - } - throw err; - } - + const resp = await this.page.waitFor(selector, { timeout }); // override default 30000ms logger.debug(`waitForSelector ${selector} resolved`); return resp; } - public async waitFor<T>( + public async waitFor( { fn, args, toEqual, + timeout, }: { fn: EvaluateFn; args: SerializableOrJSHandle[]; - toEqual: T; + toEqual: number; + timeout: number; }, context: EvaluateMetaOpts, logger: LevelLogger - ) { + ): Promise<void> { + const startTime = Date.now(); + while (true) { const result = await this.evaluate({ fn, args }, context, logger); if (result === toEqual) { return; } + if (Date.now() - startTime > timeout) { + throw new Error( + `Timed out waiting for the items selected to equal ${toEqual}. Found: ${result}. Context: ${context.context}` + ); + } await new Promise(r => setTimeout(r, WAIT_FOR_DELAY_MS)); } } - public async setViewport({ width, height, zoom }: ViewZoomWidthHeight, logger: LevelLogger) { + public async setViewport( + { width, height, zoom }: ViewZoomWidthHeight, + logger: LevelLogger + ): Promise<void> { logger.debug(`Setting viewport to width: ${width}, height: ${height}, zoom: ${zoom}`); await this.page.setViewport({ diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 6fa46b893de8ce..f90f2c7aee395b 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -3,54 +3,52 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import { i18n } from '@kbn/i18n'; +import del from 'del'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { Browser, - Page, - LaunchOptions, ConsoleMessage, + LaunchOptions, + Page, Request as PuppeteerRequest, } from 'puppeteer'; -import del from 'del'; import * as Rx from 'rxjs'; -import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; - -import { BrowserConfig, NetworkPolicy } from '../../../../types'; +import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; +import { BrowserConfig, CaptureConfig } from '../../../../types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; -import { HeadlessChromiumDriver } from '../driver'; import { safeChildProcess } from '../../safe_child_process'; -import { puppeteerLaunch } from '../puppeteer'; +import { HeadlessChromiumDriver } from '../driver'; import { getChromeLogLocation } from '../paths'; +import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type queueTimeout = number; +type ViewportConfig = BrowserConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; + private captureConfig: CaptureConfig; private browserConfig: BrowserConfig; - private queueTimeout: queueTimeout; - private networkPolicy: NetworkPolicy; private userDataDir: string; - private getChromiumArgs: (viewport: BrowserConfig['viewport']) => string[]; + private getChromiumArgs: (viewport: ViewportConfig) => string[]; constructor( binaryPath: binaryPath, logger: Logger, browserConfig: BrowserConfig, - queueTimeout: queueTimeout, - networkPolicy: NetworkPolicy + captureConfig: CaptureConfig ) { this.binaryPath = binaryPath; this.browserConfig = browserConfig; - this.queueTimeout = queueTimeout; - this.networkPolicy = networkPolicy; + this.captureConfig = captureConfig; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); - this.getChromiumArgs = (viewport: BrowserConfig['viewport']) => + this.getChromiumArgs = (viewport: ViewportConfig) => args({ userDataDir: this.userDataDir, viewport, @@ -88,7 +86,7 @@ export class HeadlessChromiumDriverFactory { * Return an observable to objects which will drive screenshot capture for a page */ createPage( - { viewport, browserTimezone }: { viewport: BrowserConfig['viewport']; browserTimezone: string }, + { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone: string }, pLogger: Logger ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable<never> }> { return Rx.Observable.create(async (observer: InnerSubscriber<any, any>) => { @@ -113,11 +111,9 @@ export class HeadlessChromiumDriverFactory { page = await browser.newPage(); - // All navigation/waitFor methods default to 30 seconds, - // which can cause the job to fail even if we bump timeouts in - // the config. Help alleviate errors like - // "TimeoutError: waiting for selector ".application" failed: timeout 30000ms exceeded" - page.setDefaultTimeout(this.queueTimeout); + // Set the default timeout for all navigation methods to the openUrl timeout (30 seconds) + // All waitFor methods have their own timeout config passed in to them + page.setDefaultTimeout(this.captureConfig.timeouts.openUrl); logger.debug(`Browser page driver created`); } catch (err) { @@ -158,7 +154,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { inspect: this.browserConfig.inspect, - networkPolicy: this.networkPolicy, + networkPolicy: this.captureConfig.networkPolicy, }); // Rx.Observable<never>: stream to interrupt page capture @@ -172,7 +168,7 @@ export class HeadlessChromiumDriverFactory { logger.debug(`deleting chromium user data directory at [${userDataDir}]`); // the unsubscribe function isn't `async` so we're going to make our best effort at // deleting the userDataDir and if it fails log an error. - del(userDataDir).catch(error => { + del(userDataDir, { force: true }).catch(error => { logger.error(`error deleting user data directory at [${userDataDir}]: [${error}]`); }); }); @@ -221,17 +217,35 @@ export class HeadlessChromiumDriverFactory { } getPageExit(browser: Browser, page: Page) { - const pageError$ = Rx.fromEvent<Error>(page, 'error').pipe(mergeMap(err => Rx.throwError(err))); + const pageError$ = Rx.fromEvent<Error>(page, 'error').pipe( + mergeMap(err => { + return Rx.throwError( + i18n.translate('xpack.reporting.browsers.chromium.errorDetected', { + defaultMessage: 'Reporting detected an error: {err}', + values: { err: err.toString() }, + }) + ); + }) + ); const uncaughtExceptionPageError$ = Rx.fromEvent<Error>(page, 'pageerror').pipe( - mergeMap(err => Rx.throwError(err)) + mergeMap(err => { + return Rx.throwError( + i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { + defaultMessage: `Reporting detected an error on the page: {err}`, + values: { err: err.toString() }, + }) + ); + }) ); const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe( mergeMap(() => Rx.throwError( new Error( - `Puppeteer was disconnected from the Chromium instance! Chromium has closed or crashed.` + i18n.translate('xpack.reporting.browsers.chromium.chromiumClosed', { + defaultMessage: `Reporting detected that Chromium has closed.`, + }) ) ) ) diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index d5f7027e025d49..d32338ae3e311e 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig, NetworkPolicy } from '../../../types'; +import { BrowserConfig, CaptureConfig } from '../../../types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -14,14 +14,7 @@ export async function createDriverFactory( binaryPath: string, logger: LevelLogger, browserConfig: BrowserConfig, - queueTimeout: number, - networkPolicy: NetworkPolicy + captureConfig: CaptureConfig ): Promise<HeadlessChromiumDriverFactory> { - return new HeadlessChromiumDriverFactory( - binaryPath, - logger, - browserConfig, - queueTimeout, - networkPolicy - ); + return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index 128df4d318c764..49c6222c9f276f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -22,8 +22,6 @@ export async function createBrowserDriverFactory( const browserType = captureConfig.browser.type; const browserAutoDownload = captureConfig.browser.autoDownload; const browserConfig = captureConfig.browser[BROWSER_TYPE]; - const networkPolicy = captureConfig.networkPolicy; - const reportingTimeout: number = config.get('xpack.reporting.queue.timeout'); if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -34,13 +32,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory( - binaryPath, - logger, - browserConfig, - reportingTimeout, - networkPolicy - ); + return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts index 4355a6a0a17732..a2d1fc7f91a290 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts @@ -31,7 +31,7 @@ export async function clean(dir: string, expectedPaths: string[]) { const path = resolvePath(dir, filename); if (!expectedPaths.includes(path)) { log(`Deleting unexpected file ${path}`); - await del(path); + await del(path, { force: true }); } }); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js index 43735979422783..113059fa2fa47e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js @@ -226,8 +226,10 @@ export class Worker extends events.EventEmitter { docOutput.content = output.content; docOutput.content_type = output.content_type || unknownMime; docOutput.max_size_reached = output.max_size_reached; - docOutput.size = output.size; docOutput.csv_contains_formulas = output.csv_contains_formulas; + docOutput.size = output.size; + docOutput.warnings = + output.warnings && output.warnings.length > 0 ? output.warnings : undefined; } else { docOutput.content = output || defaultOutput; docOutput.content_type = unknownMime; @@ -248,7 +250,11 @@ export class Worker extends events.EventEmitter { Promise.resolve(this.workerFn.call(null, job, jobSource.payload, cancellationToken)) .then(res => { // job execution was successful - this.info(`Job execution completed successfully`); + if (res && res.warnings && res.warnings.length > 0) { + this.warn(`Job execution completed with warnings`); + } else { + this.info(`Job execution completed successfully`); + } isResolved = true; resolve(res); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 6d9ae2153255fe..883276d43e27e0 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,7 +10,7 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { BrowserConfig, Logger, NetworkPolicy } from '../types'; +import { BrowserConfig, CaptureConfig, Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock<Promise<any>, any[]>; @@ -19,7 +19,7 @@ interface CreateMockBrowserDriverFactoryOpts { getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock<any, any>; } -export const mockSelectors = { +const mockSelectors = { renderComplete: 'renderedSelector', itemsCountAttribute: 'itemsSelector', screenshot: 'screenshotSelector', @@ -73,9 +73,6 @@ mockBrowserEvaluate.mockImplementation(() => { if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) { return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default ')); } - if (mockCall === contexts.CONTEXT_CHECKFORTOASTMESSAGE) { - return Promise.resolve('Toast Message'); - } throw new Error(mockCall); }); const mockScreenshot = jest.fn(); @@ -105,19 +102,20 @@ export const createMockBrowserDriverFactory = async ( } as BrowserConfig; const binaryPath = '/usr/local/share/common/secure/'; - const queueTimeout = 55; - const networkPolicy = {} as NetworkPolicy; + const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; const mockBrowserDriverFactory = await createDriverFactory( binaryPath, logger, browserConfig, - queueTimeout, - networkPolicy + captureConfig ); const mockPage = {} as Page; - const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy }); + const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { + inspect: true, + networkPolicy: captureConfig.networkPolicy, + }); // mock the driver methods as either default mocks or passed-in mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index a2eb03c3fe300f..0250e6c0a9afdb 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -19,7 +19,6 @@ export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { itemsCountAttribute: 'itemsSelector', screenshot: 'screenshotSelector', timefilterDurationAttribute: 'timefilterDurationSelector', - toastHeader: 'toastHeaderSelector', }; return mockLayout; }; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/index.ts b/x-pack/legacy/plugins/reporting/test_helpers/index.ts index 91c348ba1db3d0..491d390c370b98 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/index.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/index.ts @@ -6,5 +6,5 @@ export { createMockServer } from './create_mock_server'; export { createMockReportingCore } from './create_mock_reportingplugin'; -export { createMockBrowserDriverFactory, mockSelectors } from './create_mock_browserdriverfactory'; +export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; export { createMockLayoutInstance } from './create_mock_layoutinstance'; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 38406186c81738..b4d49fd21f230b 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -122,6 +122,11 @@ export interface CaptureConfig { maxAttempts: number; networkPolicy: NetworkPolicy; loadDelay: number; + timeouts: { + openUrl: number; + waitForElements: number; + renderComplet: number; + }; } export interface BrowserConfig { @@ -219,8 +224,9 @@ export interface JobSource<JobParamsType> { export interface JobDocOutput { content_type: string; content: string | null; - max_size_reached: boolean; size: number; + max_size_reached?: boolean; + warnings?: string[]; } export interface ESQueueWorker { diff --git a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts index 2c5a0e5eeea8a0..bebd5f7d679cfe 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header'; +import { DETECTIONS, HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header'; import { loginAndWaitForPage } from '../tasks/login'; import { navigateFromHeaderTo } from '../tasks/siem_header'; @@ -29,6 +29,11 @@ describe('top-level navigation common to all pages in the SIEM app', () => { cy.url().should('include', '/siem#/network'); }); + it('navigates to the Detections page', () => { + navigateFromHeaderTo(DETECTIONS); + cy.url().should('include', '/siem#/detections'); + }); + it('navigates to the Timelines page', () => { navigateFromHeaderTo(TIMELINES); cy.url().should('include', '/siem#/timelines'); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts new file mode 100644 index 00000000000000..f2ed9d48daaf63 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -0,0 +1,50 @@ +/* + * 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 { ELASTIC_RULES_BTN, RULES_TABLE, RULES_ROW } from '../screens/signal_detection_rules'; + +import { + changeToThreeHundredRowsPerPage, + loadPrebuiltDetectionRules, + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, + waitForPrebuiltDetectionRulesToBeLoaded, + waitForRulesToBeLoaded, +} from '../tasks/signal_detection_rules'; +import { + goToManageSignalDetectionRules, + waitForSignalsIndexToBeCreated, + waitForSignalsPanelToBeLoaded, +} from '../tasks/detections'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; + +import { DETECTIONS } from '../urls/navigation'; + +describe('Signal detection rules', () => { + before(() => { + loginAndWaitForPageWithoutDateRange(DETECTIONS); + }); + it('Loads prebuilt rules', () => { + waitForSignalsPanelToBeLoaded(); + waitForSignalsIndexToBeCreated(); + goToManageSignalDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + loadPrebuiltDetectionRules(); + waitForPrebuiltDetectionRulesToBeLoaded(); + + const expectedElasticRulesBtnText = 'Elastic rules (92)'; + cy.get(ELASTIC_RULES_BTN) + .invoke('text') + .should('eql', expectedElasticRulesBtnText); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + const expectedNumberOfRules = 92; + cy.get(RULES_TABLE).then($table => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/cypress/screens/detections.ts b/x-pack/legacy/plugins/siem/cypress/screens/detections.ts new file mode 100644 index 00000000000000..8089b028a10d48 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/screens/detections.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const LOADING_SIGNALS_PANEL = '[data-test-subj="loading-signals-panel"]'; + +export const MANAGE_SIGNAL_DETECTION_RULES_BTN = '[data-test-subj="manage-signal-detection-rules"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts index cf1059269393a0..c2dab051793c1a 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts @@ -6,6 +6,8 @@ export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a'; +export const DETECTIONS = '[data-test-subj="navigation-detections"]'; + export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.ts b/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.ts new file mode 100644 index 00000000000000..bfaa86e83f301c --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ELASTIC_RULES_BTN = '[data-test-subj="show-elastic-rules-filter-button"]'; + +export const LOAD_PREBUILT_RULES_BTN = '[data-test-subj="load-prebuilt-rules"]'; + +export const LOADING_INITIAL_PREBUILT_RULES_TABLE = + '[data-test-subj="initialLoadingPanelAllRulesTable"]'; + +export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]'; + +export const PAGINATION_POPOVER_BTN = '[data-test-subj="tablePaginationPopoverButton"]'; + +export const RULES_TABLE = '[data-test-subj="rules-table"]'; + +export const RULES_ROW = '.euiTableRow'; + +export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts new file mode 100644 index 00000000000000..4a0a565a74e27d --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LOADING_SIGNALS_PANEL, MANAGE_SIGNAL_DETECTION_RULES_BTN } from '../screens/detections'; + +export const goToManageSignalDetectionRules = () => { + cy.get(MANAGE_SIGNAL_DETECTION_RULES_BTN) + .should('exist') + .click({ force: true }); +}; + +export const waitForSignalsIndexToBeCreated = () => { + cy.request({ url: '/api/detection_engine/index', retryOnStatusCodeFailure: true }).then( + response => { + if (response.status !== 200) { + cy.wait(7500); + } + } + ); +}; + +export const waitForSignalsPanelToBeLoaded = () => { + cy.get(LOADING_SIGNALS_PANEL).should('exist'); + cy.get(LOADING_SIGNALS_PANEL).should('not.exist'); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts b/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts new file mode 100644 index 00000000000000..cc0e4bce1035af --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + LOAD_PREBUILT_RULES_BTN, + LOADING_INITIAL_PREBUILT_RULES_TABLE, + LOADING_SPINNER, + PAGINATION_POPOVER_BTN, + RULES_TABLE, + THREE_HUNDRED_ROWS, +} from '../screens/signal_detection_rules'; + +export const changeToThreeHundredRowsPerPage = () => { + cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); + cy.get(THREE_HUNDRED_ROWS).click(); +}; + +export const loadPrebuiltDetectionRules = () => { + cy.get(LOAD_PREBUILT_RULES_BTN) + .should('exist') + .click({ force: true }); +}; + +export const waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded = () => { + cy.get(LOADING_INITIAL_PREBUILT_RULES_TABLE).should('exist'); + cy.get(LOADING_INITIAL_PREBUILT_RULES_TABLE).should('not.exist'); +}; + +export const waitForPrebuiltDetectionRulesToBeLoaded = () => { + cy.get(LOAD_PREBUILT_RULES_BTN).should('not.exist'); + cy.get(RULES_TABLE).should('exist'); +}; + +export const waitForRulesToBeLoaded = () => { + cy.get(LOADING_SPINNER).should('exist'); + cy.get(LOADING_SPINNER).should('not.exist'); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts b/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts index 8fdc939e7ee51d..5e65e5aa34c186 100644 --- a/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts +++ b/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export const DETECTIONS = 'app/siem#/detections'; export const HOSTS_PAGE = '/app/siem#/hosts/allHosts'; export const HOSTS_PAGE_TAB_URLS = { allHosts: '/app/siem#/hosts/allHosts', diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 4b80b9fff2740e..b7d368639ed92c 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -30,6 +30,24 @@ DragEffects.displayName = 'DragEffects'; export const DraggablePortalContext = createContext<boolean>(false); export const useDraggablePortalContext = () => useContext(DraggablePortalContext); +/** + * Wraps the `react-beautiful-dnd` error boundary. See also: + * https://github.com/atlassian/react-beautiful-dnd/blob/v12.0.0/docs/guides/setup-problem-detection-and-error-recovery.md + * + * NOTE: This extends from `PureComponent` because, at the time of this + * writing, there's no hook equivalent for `componentDidCatch`, per + * https://reactjs.org/docs/hooks-faq.html#do-hooks-cover-all-use-cases-for-classes + */ +class DragDropErrorBoundary extends React.PureComponent { + componentDidCatch() { + this.forceUpdate(); // required for recovery + } + + render() { + return this.props.children; + } +} + const Wrapper = styled.div` display: inline-block; max-width: 100%; @@ -94,50 +112,52 @@ export const DraggableWrapper = React.memo<Props>( return ( <Wrapper data-test-subj="draggableWrapperDiv"> - <Droppable isDropDisabled={true} droppableId={getDroppableId(dataProvider.id)}> - {droppableProvided => ( - <div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}> - <Draggable - draggableId={getDraggableId(dataProvider.id)} - index={0} - key={getDraggableId(dataProvider.id)} - > - {(provided, snapshot) => ( - <ConditionalPortal - isDragging={snapshot.isDragging} - registerProvider={registerProvider} - usePortal={snapshot.isDragging && usePortal} - > - <ProviderContainer - {...provided.draggableProps} - {...provided.dragHandleProps} - ref={provided.innerRef} - data-test-subj="providerContainer" + <DragDropErrorBoundary> + <Droppable isDropDisabled={true} droppableId={getDroppableId(dataProvider.id)}> + {droppableProvided => ( + <div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}> + <Draggable + draggableId={getDraggableId(dataProvider.id)} + index={0} + key={getDraggableId(dataProvider.id)} + > + {(provided, snapshot) => ( + <ConditionalPortal isDragging={snapshot.isDragging} registerProvider={registerProvider} - style={{ - ...provided.draggableProps.style, - }} + usePortal={snapshot.isDragging && usePortal} > - {truncate && !snapshot.isDragging ? ( - <TruncatableText data-test-subj="draggable-truncatable-content"> - {render(dataProvider, provided, snapshot)} - </TruncatableText> - ) : ( - <ProviderContentWrapper - data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`} - > - {render(dataProvider, provided, snapshot)} - </ProviderContentWrapper> - )} - </ProviderContainer> - </ConditionalPortal> - )} - </Draggable> - {droppableProvided.placeholder} - </div> - )} - </Droppable> + <ProviderContainer + {...provided.draggableProps} + {...provided.dragHandleProps} + ref={provided.innerRef} + data-test-subj="providerContainer" + isDragging={snapshot.isDragging} + registerProvider={registerProvider} + style={{ + ...provided.draggableProps.style, + }} + > + {truncate && !snapshot.isDragging ? ( + <TruncatableText data-test-subj="draggable-truncatable-content"> + {render(dataProvider, provided, snapshot)} + </TruncatableText> + ) : ( + <ProviderContentWrapper + data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`} + > + {render(dataProvider, provided, snapshot)} + </ProviderContentWrapper> + )} + </ProviderContainer> + </ConditionalPortal> + )} + </Draggable> + {droppableProvided.placeholder} + </div> + )} + </Droppable> + </DragDropErrorBoundary> </Wrapper> ); }, diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index fb977417ffbbfd..38ec4a4b6f1f38 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -49,7 +49,7 @@ const EuiFlyoutContainer = styled.div<{ headerHeight: number }>` .timeline-flyout-body { overflow-y: hidden; padding: 0; - .euiFlyoutBody__overflow { + .euiFlyoutBody__overflowContent { padding: 0; } } diff --git a/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap index 0885f15b1efba3..ad2d57b948ba00 100644 --- a/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap @@ -16,6 +16,7 @@ exports[`rendering renders correctly 1`] = ` grow={false} > <EuiLoadingSpinner + data-test-subj="loading-spinner" size="xl" /> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/siem/public/components/loader/index.tsx b/x-pack/legacy/plugins/siem/public/components/loader/index.tsx index be2ce3dde951cd..e78f148418588c 100644 --- a/x-pack/legacy/plugins/siem/public/components/loader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/loader/index.tsx @@ -62,7 +62,7 @@ export const Loader = React.memo<LoaderProps>(({ children, overlay, overlayBackg <Aside overlay={overlay} overlayBackground={overlayBackground}> <FlexGroup overlay={{ overlay }}> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size={size} /> + <EuiLoadingSpinner data-test-subj="loading-spinner" size={size} /> </EuiFlexItem> {children && ( diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts index 507d6cf98ed085..d4f38d817bd6be 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts @@ -5,7 +5,7 @@ */ import { isAnError, isToasterError, errorToToaster } from './error_to_toaster'; -import { ToasterErrors } from './throw_if_not_ok'; +import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; describe('error_to_toaster', () => { let dispatchToaster = jest.fn(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts index 779befaa0cd8ef..b341016fff6efa 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts @@ -7,7 +7,7 @@ import { isError } from 'lodash/fp'; import uuid from 'uuid'; import { ActionToaster, AppToast } from '../../toasters'; -import { ToasterErrorsType, ToasterErrors } from './throw_if_not_ok'; +import { ToasterErrorsType, ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; export type ErrorToToasterArgs = Partial<AppToast> & { error: unknown; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx index 120fd8c404ffde..1ab996f88515ba 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx @@ -17,7 +17,7 @@ import { StartDatafeedResponse, StopDatafeedResponse, } from './types'; -import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../ml/api/throw_if_not_ok'; +import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../../hooks/api/throw_if_not_ok'; import { throwIfNotOk } from '../../hooks/api/api'; import { KibanaServices } from '../../lib/kibana'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index ff03a3799018c1..81f8f83217e111 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -4,24 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaServices } from '../../lib/kibana'; import { - AllCases, - Case, - CaseSnake, - Comment, - CommentSnake, - FetchCasesProps, - NewCase, - NewComment, - SortFieldCase, -} from './types'; + CaseResponse, + CasesResponse, + CaseRequest, + CommentRequest, + CommentResponse, +} from '../../../../../../plugins/case/common/api'; +import { KibanaServices } from '../../lib/kibana'; +import { AllCases, Case, Comment, FetchCasesProps, SortFieldCase } from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; -import { convertToCamelCase, convertAllCasesToCamel } from './utils'; +import { + convertToCamelCase, + convertAllCasesToCamel, + decodeCaseResponse, + decodeCasesResponse, + decodeCommentResponse, +} from './utils'; + +const CaseSavedObjectType = 'cases'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise<Case> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + const response = await KibanaServices.get().http.fetch<CaseResponse>(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, query: { @@ -29,7 +34,16 @@ export const getCase = async (caseId: string, includeComments: boolean = true): }, }); await throwIfNotOk(response.response); - return convertToCamelCase<CaseSnake, Case>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); +}; + +export const getTags = async (): Promise<string[]> => { + const response = await KibanaServices.get().http.fetch<string[]>(`${CASES_URL}/tags`, { + method: 'GET', + asResponse: true, + }); + await throwIfNotOk(response.response); + return response.body ?? []; }; export const getCases = async ({ @@ -45,70 +59,74 @@ export const getCases = async ({ sortOrder: 'desc', }, }: FetchCasesProps): Promise<AllCases> => { - const stateFilter = `case-workflow.attributes.state: ${filterOptions.state}`; + const stateFilter = `${CaseSavedObjectType}.attributes.state: ${filterOptions.state}`; const tags = [ - ...(filterOptions.tags?.reduce((acc, t) => [...acc, `case-workflow.attributes.tags: ${t}`], [ - stateFilter, - ]) ?? [stateFilter]), + ...(filterOptions.tags?.reduce( + (acc, t) => [...acc, `${CaseSavedObjectType}.attributes.tags: ${t}`], + [stateFilter] + ) ?? [stateFilter]), ]; const query = { ...queryParams, - filter: tags.join(' AND '), - search: filterOptions.search, + ...(tags.length > 0 ? { filter: tags.join(' AND ') } : {}), + ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), }; - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch<CasesResponse>(`${CASES_URL}/_find`, { method: 'GET', query, asResponse: true, }); await throwIfNotOk(response.response); - return convertAllCasesToCamel(response.body!); + return convertAllCasesToCamel(decodeCasesResponse(response.body)); }; -export const createCase = async (newCase: NewCase): Promise<Case> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { +export const postCase = async (newCase: CaseRequest): Promise<Case> => { + const response = await KibanaServices.get().http.fetch<CaseResponse>(`${CASES_URL}`, { method: 'POST', asResponse: true, body: JSON.stringify(newCase), }); await throwIfNotOk(response.response); - return convertToCamelCase<CaseSnake, Case>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); }; -export const updateCaseProperty = async ( +export const patchCase = async ( caseId: string, - updatedCase: Partial<Case>, + updatedCase: Partial<CaseRequest>, version: string -): Promise<Partial<Case>> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { +): Promise<Case> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { method: 'PATCH', asResponse: true, - body: JSON.stringify({ case: updatedCase, version }), + body: JSON.stringify({ ...updatedCase, id: caseId, version }), }); await throwIfNotOk(response.response); - return convertToCamelCase<Partial<CaseSnake>, Partial<Case>>(response.body!); + return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response.body)); }; -export const createComment = async (newComment: NewComment, caseId: string): Promise<Comment> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}/comment`, { - method: 'POST', - asResponse: true, - body: JSON.stringify(newComment), - }); +export const postComment = async (newComment: CommentRequest, caseId: string): Promise<Comment> => { + const response = await KibanaServices.get().http.fetch<CommentResponse>( + `${CASES_URL}/${caseId}/comments`, + { + method: 'POST', + asResponse: true, + body: JSON.stringify(newComment), + } + ); await throwIfNotOk(response.response); - return convertToCamelCase<CommentSnake, Comment>(response.body!); + return convertToCamelCase<CommentResponse, Comment>(decodeCommentResponse(response.body)); }; -export const updateComment = async ( +export const patchComment = async ( commentId: string, commentUpdate: string, version: string ): Promise<Partial<Comment>> => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, { + const response = await KibanaServices.get().http.fetch<CommentResponse>(`${CASES_URL}/comments`, { method: 'PATCH', asResponse: true, - body: JSON.stringify({ comment: commentUpdate, version }), + body: JSON.stringify({ comment: commentUpdate, id: commentId, version }), }); await throwIfNotOk(response.response); - return convertToCamelCase<Partial<CommentSnake>, Partial<Comment>>(response.body!); + return convertToCamelCase<CommentResponse, Comment>(decodeCommentResponse(response.body)); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 9cc9f519f3a62c..d479abdbd44891 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,31 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -interface FormData { - isNew?: boolean; -} - -export interface NewCase extends FormData { - description: string; - tags: string[]; - title: string; -} - -export interface NewComment extends FormData { - comment: string; -} - -export interface CommentSnake { - comment_id: string; - created_at: string; - created_by: ElasticUserSnake; - comment: string; - updated_at: string; - version: string; -} - export interface Comment { - commentId: string; + id: string; createdAt: string; createdBy: ElasticUser; comment: string; @@ -36,21 +13,8 @@ export interface Comment { version: string; } -export interface CaseSnake { - case_id: string; - comments: CommentSnake[]; - created_at: string; - created_by: ElasticUserSnake; - description: string; - state: string; - tags: string[]; - title: string; - updated_at: string; - version: string; -} - export interface Case { - caseId: string; + id: string; comments: Comment[]; createdAt: string; createdBy: ElasticUser; @@ -75,29 +39,18 @@ export interface FilterOptions { tags: string[]; } -export interface AllCasesSnake { - cases: CaseSnake[]; - page: number; - per_page: number; - total: number; -} - export interface AllCases { cases: Case[]; page: number; perPage: number; total: number; } + export enum SortFieldCase { createdAt = 'createdAt', updatedAt = 'updatedAt', } -export interface ElasticUserSnake { - readonly username: string; - readonly full_name?: string | null; -} - export interface ElasticUser { readonly username: string; readonly fullName?: string | null; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index ce71c26078db94..5f1dc96735d32b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -50,7 +50,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { } }; const initialData: Case = { - caseId: '', + id: '', createdAt: '', comments: [], createdBy: { @@ -83,7 +83,11 @@ export const useGetCase = (caseId: string): [CaseState] => { } } catch (error) { if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index e73b251477bf36..76e9b5c138269b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -4,15 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react'; -import { isEqual } from 'lodash/fp'; +import { useCallback, useEffect, useReducer } from 'react'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../components/toasters'; import * as i18n from './translations'; import { UpdateByKey } from './use_update_case'; -import { getCases, updateCaseProperty } from './api'; +import { getCases, patchCase } from './api'; export interface UseGetCasesState { caseCount: CaseCount; @@ -109,11 +108,11 @@ const initialData: AllCases = { total: 0, }; interface UseGetCases extends UseGetCasesState { - dispatchUpdateCaseProperty: Dispatch<UpdateCase>; - getCaseCount: Dispatch<keyof CaseCount>; - setFilters: Dispatch<SetStateAction<FilterOptions>>; - setQueryParams: Dispatch<SetStateAction<Partial<QueryParams>>>; - setSelectedCases: Dispatch<Case[]>; + dispatchUpdateCaseProperty: ({ updateKey, updateValue, caseId, version }: UpdateCase) => void; + getCaseCount: (caseState: keyof CaseCount) => void; + setFilters: (filters: FilterOptions) => void; + setQueryParams: (queryParams: QueryParams) => void; + setSelectedCases: (mySelectedCases: Case[]) => void; } export const useGetCases = (): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -138,33 +137,27 @@ export const useGetCases = (): UseGetCases => { selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); - const [filterQuery, setFilters] = useState<FilterOptions>(state.filterOptions); - const [queryParams, setQueryParams] = useState<Partial<QueryParams>>(state.queryParams); const setSelectedCases = useCallback((mySelectedCases: Case[]) => { dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); }, []); - useEffect(() => { - if (!isEqual(queryParams, state.queryParams)) { - dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: queryParams }); - } - }, [queryParams, state.queryParams]); + const setQueryParams = useCallback((newQueryParams: QueryParams) => { + dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: newQueryParams }); + }, []); - useEffect(() => { - if (!isEqual(filterQuery, state.filterOptions)) { - dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: filterQuery }); - } - }, [filterQuery, state.filterOptions]); + const setFilters = useCallback((newFilters: FilterOptions) => { + dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); + }, []); - const fetchCases = useCallback(() => { + const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => { let didCancel = false; const fetchData = async () => { dispatch({ type: 'FETCH_INIT', payload: 'cases' }); try { const response = await getCases({ - filterOptions: state.filterOptions, - queryParams: state.queryParams, + filterOptions, + queryParams, }); if (!didCancel) { dispatch({ @@ -174,7 +167,11 @@ export const useGetCases = (): UseGetCases => { } } catch (error) { if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } } @@ -183,8 +180,12 @@ export const useGetCases = (): UseGetCases => { return () => { didCancel = true; }; - }, [state.queryParams, state.filterOptions]); - useEffect(() => fetchCases(), [state.queryParams, state.filterOptions]); + }, []); + + useEffect(() => fetchCases(state.filterOptions, state.queryParams), [ + state.queryParams, + state.filterOptions, + ]); const getCaseCount = useCallback((caseState: keyof CaseCount) => { let didCancel = false; @@ -219,14 +220,14 @@ export const useGetCases = (): UseGetCases => { const fetchData = async () => { dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); try { - await updateCaseProperty( + await patchCase( caseId, { [updateKey]: updateValue }, version ?? '' // saved object versions are typed as string | undefined, hope that's not true ); if (!didCancel) { dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); - fetchCases(); + fetchCases(state.filterOptions, state.queryParams); getCaseCount('open'); getCaseCount('closed'); } @@ -242,7 +243,7 @@ export const useGetCases = (): UseGetCases => { didCancel = true; }; }, - [filterQuery, state.filterOptions] + [state.filterOptions, state.queryParams] ); return { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx index f796ae550c9ec3..7d3e00a4f2be4c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx @@ -5,12 +5,12 @@ */ import { useEffect, useReducer } from 'react'; -import chrome from 'ui/chrome'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { getTags } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; -import { throwIfNotOk } from '../../hooks/api/api'; +import * as i18n from './translations'; interface TagsState { data: string[]; @@ -63,22 +63,17 @@ export const useGetTags = (): [TagsState] => { const fetchData = async () => { dispatch({ type: FETCH_INIT }); try { - const response = await fetch(`${chrome.getBasePath()}/api/cases/tags`, { - method: 'GET', - credentials: 'same-origin', - headers: { - 'content-type': 'application/json', - 'kbn-system-api': 'true', - }, - }); + const response = await getTags(); if (!didCancel) { - await throwIfNotOk(response); - const responseJson = await response.json(); - dispatch({ type: FETCH_SUCCESS, payload: responseJson }); + dispatch({ type: FETCH_SUCCESS, payload: response }); } } catch (error) { if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 0fcc8a3a1abecb..7497b30395155d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -4,24 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; + +import { postCase } from './api'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_CASE } from './constants'; -import { Case, NewCase } from './types'; -import { createCase } from './api'; -import { getTypedPayload } from './utils'; +import { Case } from './types'; interface NewCaseState { - data: NewCase; - newCase?: Case; + caseData: Case | null; isLoading: boolean; isError: boolean; } interface Action { type: string; - payload?: NewCase | Case; + payload?: Case; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -32,19 +33,12 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => isLoading: true, isError: false, }; - case POST_NEW_CASE: - return { - ...state, - isLoading: false, - isError: false, - data: getTypedPayload<NewCase>(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newCase: getTypedPayload<Case>(action.payload), + caseData: action.payload ?? null, }; case FETCH_FAILURE: return { @@ -56,41 +50,43 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => throw new Error(); } }; -const initialData: NewCase = { - description: '', - isNew: false, - tags: [], - title: '', -}; -export const usePostCase = (): [NewCaseState, Dispatch<SetStateAction<NewCase>>] => { +interface UsePostCase extends NewCaseState { + postCase: (data: CaseRequest) => void; +} +export const usePostCase = (): UsePostCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, - data: initialData, + caseData: null, }); - const [formData, setFormData] = useState(initialData); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { - dispatch({ type: POST_NEW_CASE, payload: formData }); - }, [formData]); - - useEffect(() => { - const postCase = async () => { + const postMyCase = useCallback(async (data: CaseRequest) => { + let cancel = false; + try { dispatch({ type: FETCH_INIT }); - try { - const { isNew, ...dataWithoutIsNew } = state.data; - const response = await createCase(dataWithoutIsNew); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + const response = await postCase({ ...data, state: 'open' }); + if (!cancel) { + dispatch({ + type: FETCH_SUCCESS, + payload: response, + }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } - }; - if (state.data.isNew) { - postCase(); } - }, [state.data.isNew]); - return [state, setFormData]; + return () => { + cancel = true; + }; + }, []); + + return { ...state, postCase: postMyCase }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx index d8abda25af286b..63d24e2935c2a7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx @@ -4,25 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CommentRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; + +import { postComment } from './api'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_COMMENT } from './constants'; -import { Comment, NewComment } from './types'; -import { createComment } from './api'; -import { getTypedPayload } from './utils'; +import { Comment } from './types'; interface NewCommentState { - data: NewComment; - newComment?: Comment; + commentData: Comment | null; isLoading: boolean; isError: boolean; caseId: string; } interface Action { type: string; - payload?: NewComment | Comment; + payload?: Comment; } const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentState => { @@ -33,19 +34,12 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta isLoading: true, isError: false, }; - case POST_NEW_COMMENT: - return { - ...state, - isLoading: false, - isError: false, - data: getTypedPayload<NewComment>(action.payload), - }; case FETCH_SUCCESS: return { ...state, isLoading: false, isError: false, - newComment: getTypedPayload<Comment>(action.payload), + commentData: action.payload ?? null, }; case FETCH_FAILURE: return { @@ -57,41 +51,42 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta throw new Error(); } }; -const initialData: NewComment = { - comment: '', -}; -export const usePostComment = ( - caseId: string -): [NewCommentState, Dispatch<SetStateAction<NewComment>>] => { +interface UsePostComment extends NewCommentState { + postComment: (data: CommentRequest) => void; +} + +export const usePostComment = (caseId: string): UsePostComment => { const [state, dispatch] = useReducer(dataFetchReducer, { + commentData: null, isLoading: false, isError: false, caseId, - data: initialData, }); - const [formData, setFormData] = useState(initialData); const [, dispatchToaster] = useStateToaster(); - useEffect(() => { - dispatch({ type: POST_NEW_COMMENT, payload: formData }); - }, [formData]); - - useEffect(() => { - const postComment = async () => { + const postMyComment = useCallback(async (data: CommentRequest) => { + let cancel = false; + try { dispatch({ type: FETCH_INIT }); - try { - const { isNew, ...dataWithoutIsNew } = state.data; - const response = await createComment(dataWithoutIsNew, state.caseId); + const response = await postComment(data, state.caseId); + if (!cancel) { dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); dispatch({ type: FETCH_FAILURE }); } - }; - if (state.data.isNew) { - postComment(); } - }, [state.data.isNew]); - return [state, setFormData]; + return () => { + cancel = true; + }; + }, []); + + return { ...state, postComment: postMyComment }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index f23be526fbeb7e..21c8fb5dc7032f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -4,19 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useReducer } from 'react'; +import { useReducer, useCallback } from 'react'; + +import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { patchCase } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import * as i18n from './translations'; import { Case } from './types'; -import { updateCaseProperty } from './api'; import { getTypedPayload } from './utils'; -type UpdateKey = keyof Case; +type UpdateKey = keyof CaseRequest; interface NewCaseState { - data: Case; + caseData: Case; isLoading: boolean; isError: boolean; updateKey: UpdateKey | null; @@ -24,12 +27,12 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; - updateValue: Case[UpdateKey]; + updateValue: CaseRequest[UpdateKey]; } interface Action { type: string; - payload?: Partial<Case> | UpdateKey; + payload?: Case | UpdateKey; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -47,10 +50,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - data: { - ...state.data, - ...getTypedPayload<Case>(action.payload), - }, + caseData: getTypedPayload<Case>(action.payload), updateKey: null, }; case FETCH_FAILURE: @@ -65,32 +65,47 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => } }; -export const useUpdateCase = ( - caseId: string, - initialData: Case -): [NewCaseState, (updates: UpdateByKey) => void] => { +interface UseUpdateCase extends NewCaseState { + updateCaseProperty: (updates: UpdateByKey) => void; +} +export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, - data: initialData, + caseData: initialData, updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const dispatchUpdateCaseProperty = async ({ updateKey, updateValue }: UpdateByKey) => { - dispatch({ type: FETCH_INIT, payload: updateKey }); - try { - const response = await updateCaseProperty( - caseId, - { [updateKey]: updateValue }, - state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true - ); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE }); - } - }; + const dispatchUpdateCaseProperty = useCallback( + async ({ updateKey, updateValue }: UpdateByKey) => { + let cancel = false; + try { + dispatch({ type: FETCH_INIT, payload: updateKey }); + const response = await patchCase( + caseId, + { [updateKey]: updateValue }, + state.caseData.version + ); + if (!cancel) { + dispatch({ type: FETCH_SUCCESS, payload: response }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: FETCH_FAILURE }); + } + } + return () => { + cancel = true; + }; + }, + [state] + ); - return [state, dispatchUpdateCaseProperty]; + return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index bc8369117433a2..d7649cb7d8fdb4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useReducer, useRef } from 'react'; +import { useReducer, useCallback } from 'react'; + import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; -import * as i18n from './translations'; + +import { patchComment } from './api'; import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import * as i18n from './translations'; import { Comment } from './types'; -import { updateComment } from './api'; import { getTypedPayload } from './utils'; -interface CommetUpdateState { - data: Comment[]; +interface CommentUpdateState { + comments: Comment[]; isLoadingIds: string[]; isError: boolean; } @@ -29,7 +31,7 @@ interface Action { payload?: CommentUpdate | string; } -const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdateState => { +const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpdateState => { switch (action.type) { case FETCH_INIT: return { @@ -40,15 +42,19 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat case FETCH_SUCCESS: const updatePayload = getTypedPayload<CommentUpdate>(action.payload); - const foundIndex = state.data.findIndex( - comment => comment.commentId === updatePayload.commentId + const foundIndex = state.comments.findIndex( + comment => comment.id === updatePayload.commentId ); - state.data[foundIndex] = { ...state.data[foundIndex], ...updatePayload.update }; + const newComments = state.comments; + if (foundIndex !== -1) { + newComments[foundIndex] = { ...state.comments[foundIndex], ...updatePayload.update }; + } + return { ...state, isLoadingIds: state.isLoadingIds.filter(id => updatePayload.commentId !== id), isError: false, - data: [...state.data], + comments: newComments, }; case FETCH_FAILURE: return { @@ -63,30 +69,46 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat } }; -export const useUpdateComment = ( - comments: Comment[] -): [CommetUpdateState, (commentId: string, commentUpdate: string) => void] => { +interface UseUpdateComment extends CommentUpdateState { + updateComment: (commentId: string, commentUpdate: string) => void; +} + +export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoadingIds: [], isError: false, - data: comments, + comments, }); - const dispatchUpdateComment = useRef<(commentId: string, commentUpdate: string) => void>(); const [, dispatchToaster] = useStateToaster(); - dispatchUpdateComment.current = async (commentId: string, commentUpdate: string) => { - dispatch({ type: FETCH_INIT, payload: commentId }); - try { - const currentComment = state.data.find(comment => comment.commentId === commentId) ?? { - version: '', + const dispatchUpdateComment = useCallback( + async (commentId: string, commentUpdate: string) => { + let cancel = false; + try { + dispatch({ type: FETCH_INIT, payload: commentId }); + const currentComment = state.comments.find(comment => comment.id === commentId) ?? { + version: '', + }; + const response = await patchComment(commentId, commentUpdate, currentComment.version); + if (!cancel) { + dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: FETCH_FAILURE, payload: commentId }); + } + } + return () => { + cancel = true; }; - const response = await updateComment(commentId, commentUpdate, currentComment.version); - dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE, payload: commentId }); - } - }; + }, + [state] + ); - return [state, dispatchUpdateComment.current]; + return { ...state, updateComment: dispatchUpdateComment }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 14a3819bdfdad6..a377c496fe7267 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -5,7 +5,21 @@ */ import { camelCase, isArray, isObject, set } from 'lodash'; -import { AllCases, AllCasesSnake, Case, CaseSnake } from './types'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + CaseResponse, + CaseResponseRt, + CasesResponse, + CasesResponseRt, + throwErrors, + CommentResponse, + CommentResponseRt, +} from '../../../../../../plugins/case/common/api'; +import { ToasterErrors } from '../../hooks/api/throw_if_not_ok'; +import { AllCases, Case } from './types'; export const getTypedPayload = <T>(a: unknown): T => a as T; @@ -32,9 +46,20 @@ export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U => return acc; }, {} as U); -export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({ - cases: snakeCases.cases.map(snakeCase => convertToCamelCase<CaseSnake, Case>(snakeCase)), +export const convertAllCasesToCamel = (snakeCases: CasesResponse): AllCases => ({ + cases: snakeCases.cases.map(snakeCase => convertToCamelCase<CaseResponse, Case>(snakeCase)), page: snakeCases.page, perPage: snakeCases.per_page, total: snakeCases.total, }); + +export const createToasterPlainError = (message: string) => new ToasterErrors([message]); + +export const decodeCaseResponse = (respCase?: CaseResponse) => + pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCasesResponse = (respCases?: CasesResponse) => + pipe(CasesResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCommentResponse = (respComment?: CommentResponse) => + pipe(CommentResponseRt.decode(respComment), fold(throwErrors(createToasterPlainError), identity)); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index b348678e789f88..05446577a0fa05 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -20,7 +20,7 @@ import { getPrePackagedRulesStatus, } from './api'; import { ruleMock, rulesMock } from './mock'; -import { ToasterErrors } from '../../../components/ml/api/throw_if_not_ok'; +import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts index 4f45b480772f2b..79dae5b8acb875 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class SignalIndexError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts index d6d8cccfb45401..227699af71b421 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class PostSignalError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts index 5cd458a7fe9aa3..19915e898bbebd 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok'; +import { MessageBody } from '../../../../hooks/api/throw_if_not_ok'; export class PrivilegeUserError extends Error { message: string = ''; diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx index 69848c08fa3f88..1dfd6416531ee1 100644 --- a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx +++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx @@ -6,7 +6,7 @@ import * as i18n from '../translations'; import { StartServices } from '../../plugin'; -import { parseJsonFromBody, ToasterErrors } from '../../components/ml/api/throw_if_not_ok'; +import { parseJsonFromBody, ToasterErrors } from './throw_if_not_ok'; import { IndexPatternSavedObject, IndexPatternSavedObjectAttributes } from '../types'; /** diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts similarity index 99% rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts index 9fd00105352031..bc0c765d6f2dfa 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts +++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts @@ -14,7 +14,7 @@ import { ToasterErrors, tryParseResponse, } from './throw_if_not_ok'; -import { SetupMlResponse } from '../../ml_popover/types'; +import { SetupMlResponse } from '../../components/ml_popover/types'; describe('throw_if_not_ok', () => { afterEach(() => { diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts similarity index 91% rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts index 6ca843207a15e1..7d70106b0e5627 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts +++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts @@ -6,11 +6,11 @@ import { has } from 'lodash/fp'; -import * as i18n from './translations'; -import { MlError } from '../types'; -import { SetupMlResponse } from '../../ml_popover/types'; +import * as i18n from '../../components/ml/api/translations'; +import { MlError } from '../../components/ml/types'; +import { SetupMlResponse } from '../../components/ml_popover/types'; -export { MessageBody, parseJsonFromBody } from '../../../utils/api'; +export { MessageBody, parseJsonFromBody } from '../../utils/api'; export interface MlStartJobError { error: MlError; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index c8e0dafcf5742b..16c6101b80d40a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -3,15 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; + import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { Form, useForm, UseField } from '../../../../shared_imports'; -import { NewComment } from '../../../../containers/case/types'; + +import { CommentRequest } from '../../../../../../../../plugins/case/common/api'; import { usePostComment } from '../../../../containers/case/use_post_comment'; -import { schema } from './schema'; -import * as i18n from '../../translations'; import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; +import { Form, useForm, UseField } from '../../../../shared_imports'; +import * as i18n from '../../translations'; +import { schema } from './schema'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; @@ -19,24 +21,26 @@ const MySpinner = styled(EuiLoadingSpinner)` left: 50%; `; +const initialCommentValue: CommentRequest = { + comment: '', +}; + export const AddComment = React.memo<{ caseId: string; }>(({ caseId }) => { - const [{ data, isLoading, newComment }, setFormData] = usePostComment(caseId); - const { form } = useForm({ - defaultValue: data, + const { commentData, isLoading, postComment } = usePostComment(caseId); + const { form } = useForm<CommentRequest>({ + defaultValue: initialCommentValue, options: { stripEmptyFields: false }, schema, }); const onSubmit = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); - if (isValid && newData.comment) { - setFormData({ ...newData, isNew: true } as NewComment); - } else if (isValid && data.comment) { - setFormData({ ...data, ...newData, isNew: true } as NewComment); + const { isValid, data } = await form.submit(); + if (isValid) { + await postComment(data); } - }, [form, data]); + }, [form]); return ( <> @@ -64,7 +68,7 @@ export const AddComment = React.memo<{ }} /> </Form> - {newComment && + {commentData != null && 'TO DO new comment got added but we didnt update the UI yet. Refresh the page to see your comment ;)'} </> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx index 5f30f59149d99b..c61874a8dabfc1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx @@ -3,12 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import { CommentRequest } from '../../../../../../../../plugins/case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; import * as i18n from '../../translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schema: FormSchema<CommentRequest> = { comment: { type: FIELD_TYPES.TEXTAREA, validations: [ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index a054d685399bcc..2e57e5f2f95d9f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -11,7 +11,7 @@ export const useGetCasesMockState: UseGetCasesState = { data: { cases: [ { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, comments: [], @@ -23,7 +23,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', + id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, comments: [], @@ -35,7 +35,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', + id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, comments: [], @@ -47,7 +47,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', + id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, comments: [], @@ -59,7 +59,7 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { - caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', + id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, comments: [], diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx index 5dad19b1e54d30..0ec09f2b579182 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx @@ -24,7 +24,7 @@ export const getActions = ({ icon: 'trash', name: i18n.DELETE, // eslint-disable-next-line no-console - onClick: ({ caseId }: Case) => console.log('TO DO Delete case', caseId), + onClick: ({ id }: Case) => console.log('TO DO Delete case', id), type: 'icon', 'data-test-subj': 'action-delete', }, @@ -37,7 +37,7 @@ export const getActions = ({ dispatchUpdate({ updateKey: 'state', updateValue: 'closed', - caseId: theCase.caseId, + caseId: theCase.id, version: theCase.version, }), type: 'icon', @@ -51,7 +51,7 @@ export const getActions = ({ dispatchUpdate({ updateKey: 'state', updateValue: 'open', - caseId: theCase.caseId, + caseId: theCase.id, version: theCase.version, }), type: 'icon', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 41a2bdf52d5a17..f6ed2694fdc402 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -42,9 +42,9 @@ export const getCasesColumns = ( { name: i18n.NAME, render: (theCase: Case) => { - if (theCase.caseId != null && theCase.title != null) { + if (theCase.id != null && theCase.title != null) { const caseDetailsLinkComponent = ( - <CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink> + <CaseDetailsLink detailName={theCase.id}>{theCase.title}</CaseDetailsLink> ); return theCase.state === 'open' ? ( caseDetailsLinkComponent diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index dd584f3f716b62..40a76c636954ff 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -41,7 +41,7 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].caseId}`); + ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 89d321c6d106a5..c2d3cae6774b0f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,11 +10,11 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', comments: [ { comment: 'Solve this fast!', - commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', createdAt: '2020-02-20T23:06:33.798Z', createdBy: { fullName: 'Steph Milovic', @@ -36,11 +36,11 @@ export const caseProps: CaseProps = { }; export const data: Case = { - caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', comments: [ { comment: 'Solve this fast!', - commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', createdAt: '2020-02-20T23:06:33.798Z', createdBy: { fullName: 'Steph Milovic', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 1539b3de5a0c13..e3bbfc0a83d718 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -12,16 +12,17 @@ import { caseProps, data } from './__mock__'; import { TestProviders } from '../../../../mock'; describe('CaseView ', () => { - const dispatchUpdateCaseProperty = jest.fn(); + const updateCaseProperty = jest.fn(); beforeEach(() => { jest.resetAllMocks(); - jest - .spyOn(apiHook, 'useUpdateCase') - .mockReturnValue([ - { data, isLoading: false, isError: false, updateKey: null }, - dispatchUpdateCaseProperty, - ]); + jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue({ + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }); }); it('should render CaseComponent', () => { @@ -79,7 +80,7 @@ describe('CaseView ', () => { .find('input[data-test-subj="toggle-case-state"]') .simulate('change', { target: { value: false } }); - expect(dispatchUpdateCaseProperty).toBeCalledWith({ + expect(updateCaseProperty).toBeCalledWith({ updateKey: 'state', updateValue: 'closed', }); @@ -94,7 +95,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}-avatar"] [data-test-subj="user-action-avatar"]` + `div[data-test-subj="user-action-${data.comments[0].id}-avatar"] [data-test-subj="user-action-avatar"]` ) .first() .prop('name') @@ -103,7 +104,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="user-action-title"] strong` + `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="user-action-title"] strong` ) .first() .text() @@ -112,7 +113,7 @@ describe('CaseView ', () => { expect( wrapper .find( - `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="markdown"]` + `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="markdown"]` ) .first() .prop('source') diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 605f9e8fa17134..c917d27aebea35 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -60,10 +60,7 @@ export interface CaseProps { } export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => { - const [{ data, isLoading, updateKey }, dispatchUpdateCaseProperty] = useUpdateCase( - caseId, - initialData - ); + const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); const onUpdateField = useCallback( (newUpdateKey: keyof Case, updateValue: Case[keyof Case]) => { @@ -71,7 +68,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => case 'title': const titleUpdate = getTypedPayload<string>(updateValue); if (titleUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'title', updateValue: titleUpdate, }); @@ -80,7 +77,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => case 'description': const descriptionUpdate = getTypedPayload<string>(updateValue); if (descriptionUpdate.length > 0) { - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'description', updateValue: descriptionUpdate, }); @@ -88,15 +85,15 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => break; case 'tags': const tagsUpdate = getTypedPayload<string[]>(updateValue); - dispatchUpdateCaseProperty({ + updateCaseProperty({ updateKey: 'tags', updateValue: tagsUpdate, }); break; case 'state': const stateUpdate = getTypedPayload<string>(updateValue); - if (data.state !== updateValue) { - dispatchUpdateCaseProperty({ + if (caseData.state !== updateValue) { + updateCaseProperty({ updateKey: 'state', updateValue: stateUpdate, }); @@ -105,7 +102,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => return null; } }, - [dispatchUpdateCaseProperty, data.state] + [updateCaseProperty, caseData.state] ); // TO DO refactor each of these const's into their own components @@ -146,11 +143,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => titleNode={ <EditableTitle isLoading={isLoading && updateKey === 'title'} - title={data.title} + title={caseData.title} onSubmit={onSubmit} /> } - title={data.title} + title={caseData.title} > <EuiFlexGroup gutterSize="l" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -160,10 +157,10 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle> <EuiDescriptionListDescription> <EuiBadge - color={data.state === 'open' ? 'secondary' : 'danger'} + color={caseData.state === 'open' ? 'secondary' : 'danger'} data-test-subj="case-view-state" > - {data.state} + {caseData.state} </EuiBadge> </EuiDescriptionListDescription> </EuiFlexItem> @@ -172,7 +169,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiDescriptionListDescription> <FormattedRelativePreferenceDate data-test-subj="case-view-createdAt" - value={data.createdAt} + value={caseData.createdAt} /> </EuiDescriptionListDescription> </EuiFlexItem> @@ -184,10 +181,10 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiFlexItem> <EuiButtonToggle data-test-subj="toggle-case-state" - iconType={data.state === 'open' ? 'checkInCircleFilled' : 'magnet'} + iconType={caseData.state === 'open' ? 'checkInCircleFilled' : 'magnet'} isLoading={isLoading && updateKey === 'state'} - isSelected={data.state === 'open'} - label={data.state === 'open' ? 'Close case' : 'Reopen case'} + isSelected={caseData.state === 'open'} + label={caseData.state === 'open' ? 'Close case' : 'Reopen case'} onChange={toggleStateCase} /> </EuiFlexItem> @@ -204,7 +201,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <EuiFlexGroup> <EuiFlexItem grow={6}> <UserActionTree - data={data} + data={caseData} isLoadingDescription={isLoading && updateKey === 'description'} onUpdateField={onUpdateField} /> @@ -213,11 +210,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => <UserList data-test-subj="case-view-user-list" headline={i18n.REPORTER} - users={[data.createdBy]} + users={[caseData.createdBy]} /> <TagList data-test-subj="case-view-tag-list" - tags={data.tags} + tags={caseData.tags} onSubmit={onSubmitTags} isLoading={isLoading && updateKey === 'tags'} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 65d7256fd6e20c..840792f510fc00 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -14,8 +14,9 @@ import { } from '@elastic/eui'; import styled, { css } from 'styled-components'; import { Redirect } from 'react-router-dom'; + +import { CaseRequest } from '../../../../../../../../plugins/case/common/api'; import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports'; -import { NewCase } from '../../../../containers/case/types'; import { usePostCase } from '../../../../containers/case/use_post_case'; import { schema } from './schema'; import * as i18n from '../../translations'; @@ -42,30 +43,37 @@ const MySpinner = styled(EuiLoadingSpinner)` z-index: 99; `; +const initialCaseValue: CaseRequest = { + description: '', + state: 'open', + tags: [], + title: '', +}; + export const Create = React.memo(() => { - const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const { caseData, isLoading, postCase } = usePostCase(); const [isCancel, setIsCancel] = useState(false); - const { form } = useForm({ - defaultValue: data, + const { form } = useForm<CaseRequest>({ + defaultValue: initialCaseValue, options: { stripEmptyFields: false }, schema, }); const onSubmit = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); - if (isValid && newData.description) { - setFormData({ ...newData, isNew: true } as NewCase); - } else if (isValid && data.description) { - setFormData({ ...data, ...newData, isNew: true } as NewCase); + const { isValid, data } = await form.submit(); + if (isValid) { + await postCase(data); } - }, [form, data]); + }, [form]); - if (newCase && newCase.caseId) { - return <Redirect to={`/${SiemPageName.case}/${newCase.caseId}`} />; + if (caseData != null && caseData.id) { + return <Redirect to={`/${SiemPageName.case}/${caseData.id}`} />; } + if (isCancel) { return <Redirect to={`/${SiemPageName.case}`} />; } + return ( <EuiPanel> {isLoading && <MySpinner size="xl" />} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx index c81a31f0d4f3f2..91d3b77493b034 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -4,13 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CaseRequest } from '../../../../../../../../plugins/case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; -import { OptionalFieldLabel } from './optional_field_label'; import * as i18n from '../../translations'; +import { OptionalFieldLabel } from './optional_field_label'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schemaTags = { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, +}; + +export const schema: FormSchema<CaseRequest> = { title: { type: FIELD_TYPES.TEXT, label: i18n.NAME, @@ -28,10 +36,5 @@ export const schema: FormSchema = { }, ], }, - tags: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - }, + tags: schemaTags, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx index 26a89408069fbc..50ba114de528e0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { FormSchema } from '../../../../shared_imports'; -import { schema as createSchema } from '../create/schema'; +import { schemaTags } from '../create/schema'; export const schema: FormSchema = { - tags: createSchema.tags, + tags: schemaTags, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 63e0bbeb443c22..b68bfd73e50e9a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -23,10 +23,8 @@ const DescriptionId = 'description'; const NewId = 'newComent'; export const UserActionTree = React.memo( - ({ data, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { - const [{ data: comments, isLoadingIds }, dispatchUpdateComment] = useUpdateComment( - data.comments - ); + ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + const { comments, isLoadingIds, updateComment } = useUpdateComment(caseData.comments); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]); @@ -44,16 +42,16 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - dispatchUpdateComment(id, content); + updateComment(id, content); }, - [handleManageMarkdownEditId, dispatchUpdateComment] + [handleManageMarkdownEditId, updateComment] ); const MarkdownDescription = useMemo( () => ( <UserActionMarkdown id={DescriptionId} - content={data.description} + content={caseData.description} isEditable={manageMarkdownEditIds.includes(DescriptionId)} onSaveContent={(content: string) => { handleManageMarkdownEditId(DescriptionId); @@ -62,45 +60,45 @@ export const UserActionTree = React.memo( onChangeEditable={handleManageMarkdownEditId} /> ), - [data.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] + [caseData.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] ); - const MarkdownNewComment = useMemo(() => <AddComment caseId={data.caseId} />, [data.caseId]); + const MarkdownNewComment = useMemo(() => <AddComment caseId={caseData.id} />, [caseData.id]); return ( <> <UserActionItem - createdAt={data.createdAt} + createdAt={caseData.createdAt} id={DescriptionId} isEditable={manageMarkdownEditIds.includes(DescriptionId)} isLoading={isLoadingDescription} labelAction={i18n.EDIT_DESCRIPTION} labelTitle={i18n.ADDED_DESCRIPTION} - fullName={data.createdBy.fullName ?? data.createdBy.username} + fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} - userName={data.createdBy.username} + userName={caseData.createdBy.username} /> {comments.map(comment => ( <UserActionItem - key={comment.commentId} + key={comment.id} createdAt={comment.createdAt} - id={comment.commentId} - isEditable={manageMarkdownEditIds.includes(comment.commentId)} - isLoading={isLoadingIds.includes(comment.commentId)} + id={comment.id} + isEditable={manageMarkdownEditIds.includes(comment.id)} + isLoading={isLoadingIds.includes(comment.id)} labelAction={i18n.EDIT_COMMENT} labelTitle={i18n.ADDED_COMMENT} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ <UserActionMarkdown - id={comment.commentId} + id={comment.id} content={comment.comment} - isEditable={manageMarkdownEditIds.includes(comment.commentId)} + isEditable={manageMarkdownEditIds.includes(comment.id)} onChangeEditable={handleManageMarkdownEditId} - onSaveContent={handleSaveComment.bind(null, comment.commentId)} + onSaveContent={handleSaveComment.bind(null, comment.id)} /> } - onEdit={handleManageMarkdownEditId.bind(null, comment.commentId)} + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} userName={comment.createdBy.username} /> ))} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 75f19218d9b387..afd325f5399666 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -290,7 +290,7 @@ const SignalsTableComponent: React.FC<SignalsTableComponentProps> = ({ return ( <EuiPanel> <HeaderSection title={i18n.SIGNALS_TABLE_TITLE} /> - <EuiLoadingContent /> + <EuiLoadingContent data-test-subj="loading-signals-panel" /> </EuiPanel> ); } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index c3fb907ae83e1e..1bd7ab2c4f1ae0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -129,7 +129,12 @@ const DetectionEnginePageComponent: React.FC<PropsFromRedux> = ({ } title={i18n.PAGE_TITLE} > - <EuiButton fill href={getRulesUrl()} iconType="gear"> + <EuiButton + fill + href={getRulesUrl()} + iconType="gear" + data-test-subj="manage-signal-detection-rules" + > {i18n.BUTTON_MANAGE_RULES} </EuiButton> </DetectionEngineHeaderPage> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index e7d68164c4ef4c..bb718d80298172 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -317,6 +317,7 @@ export const AllRules = React.memo<AllRulesProps>( </UtilityBarSection> </UtilityBar> <MyEuiBasicTable + data-test-subj="rules-table" columns={columns} isSelectable={!hasNoPermissions ?? false} itemId="id" diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx index 41b7fafd6becdb..1cff4751e8188c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pre_packaged_rules/load_empty_prompt.tsx @@ -43,6 +43,7 @@ const PrePackagedRulesPromptComponent: React.FC<PrePackagedRulesPromptProps> = ( isDisabled={userHasNoPermissions} isLoading={loading} onClick={handlePreBuiltCreation} + data-test-subj="load-prebuilt-rules" > {i18n.PRE_BUILT_ACTION} </EuiButton> diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts deleted file mode 100644 index 80cdb9e979a684..00000000000000 --- a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -/* eslint-disable @typescript-eslint/no-empty-interface */ -/* eslint-disable @typescript-eslint/camelcase */ -import { CaseAttributes, CommentAttributes } from '../../../../../../../x-pack/plugins/case/server'; -import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; - -// Temporary file to write mappings for case -// while Saved Object Mappings API is programmed for the NP -// See: https://github.com/elastic/kibana/issues/50309 - -export const caseSavedObjectType = 'case-workflow'; -export const caseCommentSavedObjectType = 'case-workflow-comment'; - -export const caseSavedObjectMappings: { - [caseSavedObjectType]: ElasticsearchMappingOf<CaseAttributes>; -} = { - [caseSavedObjectType]: { - properties: { - created_at: { - type: 'date', - }, - description: { - type: 'text', - }, - title: { - type: 'keyword', - }, - created_by: { - properties: { - username: { - type: 'keyword', - }, - full_name: { - type: 'keyword', - }, - }, - }, - state: { - type: 'keyword', - }, - tags: { - type: 'keyword', - }, - updated_at: { - type: 'date', - }, - }, - }, -}; - -export const caseCommentSavedObjectMappings: { - [caseCommentSavedObjectType]: ElasticsearchMappingOf<CommentAttributes>; -} = { - [caseCommentSavedObjectType]: { - properties: { - comment: { - type: 'text', - }, - created_at: { - type: 'date', - }, - created_by: { - properties: { - full_name: { - type: 'keyword', - }, - username: { - type: 'keyword', - }, - }, - }, - updated_at: { - type: 'date', - }, - }, - }, -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 1d904b2b349ae7..5fdef59a72f04f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -43,6 +43,7 @@ export const patchRules = async ({ type, references, version, + throttle, }: PatchRuleParams): Promise<PartialAlert | null> => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -73,6 +74,7 @@ export const patchRules = async ({ type, references, version, + throttle, }); const nextParams = defaults( @@ -108,6 +110,7 @@ export const patchRules = async ({ id: rule.id, data: { tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable), + throttle: throttle ?? rule.throttle ?? null, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index c63237c93daf49..7889267a7267b5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -41,6 +41,7 @@ export const updatePrepackagedRules = async ( threat, references, version, + throttle, } = rule; // Note: we do not pass down enabled as we do not want to suddenly disable @@ -73,6 +74,7 @@ export const updatePrepackagedRules = async ( threat, references, version, + throttle, }); }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index 9ead8313b2c91c..3a10841b70d7e1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -42,6 +42,7 @@ export const updateRules = async ({ type, references, version, + throttle, }: UpdateRuleParams): Promise<PartialAlert | null> => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -72,6 +73,7 @@ export const updateRules = async ({ type, references, version, + throttle, }); const update = await alertsClient.update({ @@ -81,6 +83,7 @@ export const updateRules = async ({ name, schedule: { interval }, actions: rule.actions, + throttle: throttle ?? rule.throttle ?? null, params: { description, ruleId: rule.params.ruleId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 77eefd3d1d855b..5e5ff157c92c65 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -49,6 +49,7 @@ export interface RuleAlertParams { threat: ThreatParams[] | undefined | null; type: 'query' | 'saved_query'; version: number; + throttle?: string; } export type RuleTypeParams = Omit<RuleAlertParams, 'name' | 'enabled' | 'interval' | 'tags'>; diff --git a/x-pack/legacy/plugins/siem/server/saved_objects.ts b/x-pack/legacy/plugins/siem/server/saved_objects.ts index 58da333c7bc9a8..76d8837883b8b7 100644 --- a/x-pack/legacy/plugins/siem/server/saved_objects.ts +++ b/x-pack/legacy/plugins/siem/server/saved_objects.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { noteSavedObjectType, noteSavedObjectMappings } from './lib/note/saved_object_mappings'; import { pinnedEventSavedObjectType, @@ -16,10 +17,6 @@ import { ruleStatusSavedObjectMappings, ruleStatusSavedObjectType, } from './lib/detection_engine/rules/saved_object_mappings'; -import { - caseSavedObjectMappings, - caseCommentSavedObjectMappings, -} from './lib/case/saved_object_mappings'; export { noteSavedObjectType, @@ -31,8 +28,5 @@ export const savedObjectMappings = { ...timelineSavedObjectMappings, ...noteSavedObjectMappings, ...pinnedEventSavedObjectMappings, - // TODO: Remove once while Saved Object Mappings API is programmed for the NP See: https://github.com/elastic/kibana/issues/50309 - ...caseSavedObjectMappings, - ...caseCommentSavedObjectMappings, ...ruleStatusSavedObjectMappings, }; diff --git a/x-pack/legacy/plugins/transform/index.ts b/x-pack/legacy/plugins/transform/index.ts index 10f4732152c43a..a4b980c0bf8f3a 100644 --- a/x-pack/legacy/plugins/transform/index.ts +++ b/x-pack/legacy/plugins/transform/index.ts @@ -4,19 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve } from 'path'; - -import { PLUGIN } from './common/constants'; - export function transform(kibana: any) { return new kibana.Plugin({ - id: PLUGIN.ID, + id: 'transform', configPrefix: 'xpack.transform', - publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/app/index.scss'), - managementSections: ['plugins/transform'], - }, }); } diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts deleted file mode 100644 index 802599aaedd4f8..00000000000000 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useAppDependencies } from '../app_dependencies'; -import { PreviewRequestBody, TransformId } from '../common'; -import { httpFactory, Http } from '../services/http_service'; - -import { EsIndex, TransformEndpointRequest, TransformEndpointResult } from './use_api_types'; - -const apiFactory = (basePath: string, indicesBasePath: string, http: Http) => ({ - getTransforms(transformId?: TransformId): Promise<any> { - const transformIdString = transformId !== undefined ? `/${transformId}` : ''; - return http({ - url: `${basePath}/transforms${transformIdString}`, - method: 'GET', - }); - }, - getTransformsStats(transformId?: TransformId): Promise<any> { - if (transformId !== undefined) { - return http({ - url: `${basePath}/transforms/${transformId}/_stats`, - method: 'GET', - }); - } - - return http({ - url: `${basePath}/transforms/_stats`, - method: 'GET', - }); - }, - createTransform(transformId: TransformId, transformConfig: any): Promise<any> { - return http({ - url: `${basePath}/transforms/${transformId}`, - method: 'PUT', - data: transformConfig, - }); - }, - deleteTransforms(transformsInfo: TransformEndpointRequest[]) { - return http({ - url: `${basePath}/delete_transforms`, - method: 'POST', - data: transformsInfo, - }) as Promise<TransformEndpointResult>; - }, - getTransformsPreview(obj: PreviewRequestBody): Promise<any> { - return http({ - url: `${basePath}/transforms/_preview`, - method: 'POST', - data: obj, - }); - }, - startTransforms(transformsInfo: TransformEndpointRequest[]) { - return http({ - url: `${basePath}/start_transforms`, - method: 'POST', - data: { - transformsInfo, - }, - }) as Promise<TransformEndpointResult>; - }, - stopTransforms(transformsInfo: TransformEndpointRequest[]) { - return http({ - url: `${basePath}/stop_transforms`, - method: 'POST', - data: { - transformsInfo, - }, - }) as Promise<TransformEndpointResult>; - }, - getTransformAuditMessages(transformId: TransformId): Promise<any> { - return http({ - url: `${basePath}/transforms/${transformId}/messages`, - method: 'GET', - }); - }, - esSearch(payload: any) { - return http({ - url: `${basePath}/es_search`, - method: 'POST', - data: payload, - }) as Promise<any>; - }, - getIndices() { - return http({ - url: `${indicesBasePath}/index_management/indices`, - method: 'GET', - }) as Promise<EsIndex[]>; - }, -}); - -export const useApi = () => { - const appDeps = useAppDependencies(); - - const basePath = appDeps.core.http.basePath.prepend('/api/transform'); - const indicesBasePath = appDeps.core.http.basePath.prepend('/api'); - const xsrfToken = appDeps.plugins.xsrfToken; - const http = httpFactory(xsrfToken); - - return apiFactory(basePath, indicesBasePath, http); -}; diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts deleted file mode 100644 index d0f81a058b7b3c..00000000000000 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TransformId, TRANSFORM_STATE } from '../common'; - -export interface EsIndex { - name: string; -} - -export interface TransformEndpointRequest { - id: TransformId; - state?: TRANSFORM_STATE; -} - -export interface ResultData { - success: boolean; - error?: any; -} - -export interface TransformEndpointResult { - [key: string]: ResultData; -} diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx deleted file mode 100644 index 715573e3a6f67d..00000000000000 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; -import ReactDOM from 'react-dom'; -import { act } from 'react-dom/test-utils'; - -import { SimpleQuery } from '../../../../common'; -import { - SOURCE_INDEX_STATUS, - useSourceIndexData, - UseSourceIndexDataReturnType, -} from './use_source_index_data'; - -jest.mock('../../../../hooks/use_api'); - -type Callback = () => void; -interface TestHookProps { - callback: Callback; -} - -const TestHook: FC<TestHookProps> = ({ callback }) => { - callback(); - return null; -}; - -const testHook = (callback: Callback) => { - const container = document.createElement('div'); - document.body.appendChild(container); - act(() => { - ReactDOM.render(<TestHook callback={callback} />, container); - }); -}; - -const query: SimpleQuery = { - query_string: { - query: '*', - default_operator: 'AND', - }, -}; - -let sourceIndexObj: UseSourceIndexDataReturnType; - -describe('useSourceIndexData', () => { - test('indexPattern set triggers loading', () => { - testHook(() => { - act(() => { - sourceIndexObj = useSourceIndexData( - { id: 'the-id', title: 'the-title', fields: [] }, - query, - { pageIndex: 0, pageSize: 10 } - ); - }); - }); - - expect(sourceIndexObj.errorMessage).toBe(''); - expect(sourceIndexObj.status).toBe(SOURCE_INDEX_STATUS.LOADING); - expect(sourceIndexObj.tableItems).toEqual([]); - }); - - // TODO add more tests to check data retrieved via `api.esSearch()`. - // This needs more investigation in regards to jest/enzyme's React Hooks support. -}); diff --git a/x-pack/legacy/plugins/transform/public/app/services/http_service.ts b/x-pack/legacy/plugins/transform/public/app/services/http_service.ts deleted file mode 100644 index fa4c8d1ba78447..00000000000000 --- a/x-pack/legacy/plugins/transform/public/app/services/http_service.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// service for interacting with the server -import { Dictionary } from '../../../common/types/common'; - -export type Http = (options: Dictionary<any>) => Promise<unknown>; - -export function httpFactory(xsrfToken: string) { - return function http(options: Dictionary<any>) { - return new Promise((resolve, reject) => { - if (options && options.url) { - let url = ''; - url = url + (options.url || ''); - const headers = { - 'kbn-system-request': true, - 'Content-Type': 'application/json', - 'kbn-version': xsrfToken, - ...options.headers, - }; - - const allHeaders = - options.headers === undefined ? headers : { ...options.headers, ...headers }; - const body = options.data === undefined ? null : JSON.stringify(options.data); - - const payload: Dictionary<any> = { - method: options.method || 'GET', - headers: allHeaders, - credentials: 'same-origin', - }; - - if (body !== null) { - payload.body = body; - } - - fetch(url, payload) - .then(resp => { - resp.json().then(resp.ok === true ? resolve : reject); - }) - .catch(resp => { - reject(resp); - }); - } else { - reject(); - } - }); - }; -} diff --git a/x-pack/legacy/plugins/transform/public/app/services/ui_metric/ui_metric.ts b/x-pack/legacy/plugins/transform/public/app/services/ui_metric/ui_metric.ts deleted file mode 100644 index a2f0a6e1a54821..00000000000000 --- a/x-pack/legacy/plugins/transform/public/app/services/ui_metric/ui_metric.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { UIM_APP_NAME } from '../../constants'; -import { - createUiStatsReporter, - METRIC_TYPE, -} from '../../../../../../../../src/legacy/core_plugins/ui_metric/public'; - -class UiMetricService { - track?: ReturnType<typeof createUiStatsReporter>; - - public init = (getReporter: typeof createUiStatsReporter): void => { - this.track = getReporter(UIM_APP_NAME); - }; - - public trackUiMetric = (eventName: string): void => { - if (!this.track) throw Error('UiMetricService not initialized.'); - return this.track(METRIC_TYPE.COUNT, eventName); - }; -} - -export const uiMetricService = new UiMetricService(); diff --git a/x-pack/legacy/plugins/transform/public/index.ts b/x-pack/legacy/plugins/transform/public/index.ts deleted file mode 100644 index 28c9c06f86e241..00000000000000 --- a/x-pack/legacy/plugins/transform/public/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Plugin as TransformPlugin } from './plugin'; -import { createPublicShim } from './shim'; - -const { core, plugins } = createPublicShim(); -const transformPlugin = new TransformPlugin(); -transformPlugin.start(core, plugins); diff --git a/x-pack/legacy/plugins/transform/public/plugin.ts b/x-pack/legacy/plugins/transform/public/plugin.ts deleted file mode 100644 index 7b5fbbb4a21519..00000000000000 --- a/x-pack/legacy/plugins/transform/public/plugin.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import { renderApp } from './app/app'; -import { ShimCore, ShimPlugins } from './shim'; - -import { breadcrumbService } from './app/services/navigation'; -import { docTitleService } from './app/services/navigation'; -import { textService } from './app/services/text'; -import { uiMetricService } from './app/services/ui_metric'; - -export class Plugin { - public start(core: ShimCore, plugins: ShimPlugins): void { - const { - http, - chrome, - documentation, - docLinks, - docTitle, - injectedMetadata, - notifications, - uiSettings, - savedObjects, - overlays, - } = core; - const { data, management, uiMetric, xsrfToken } = plugins; - - // AppCore/AppPlugins to be passed on as React context - const appDependencies = { - core: { - chrome, - documentation, - docLinks, - http, - i18n: core.i18n, - injectedMetadata, - notifications, - uiSettings, - savedObjects, - overlays, - }, - plugins: { - data, - management, - xsrfToken, - }, - }; - - // Register management section - const esSection = management.sections.getSection('elasticsearch'); - if (esSection !== undefined) { - esSection.registerApp({ - id: 'transform', - title: i18n.translate('xpack.transform.appTitle', { - defaultMessage: 'Transforms', - }), - order: 3, - mount(params) { - breadcrumbService.setup(params.setBreadcrumbs); - params.setBreadcrumbs([ - { - text: i18n.translate('xpack.transform.breadcrumbsTitle', { - defaultMessage: 'Transforms', - }), - }, - ]); - - return renderApp(params.element, appDependencies); - }, - }); - } - - // Initialize services - textService.init(); - uiMetricService.init(uiMetric.createUiStatsReporter); - docTitleService.init(docTitle.change); - } -} diff --git a/x-pack/legacy/plugins/transform/public/shim.ts b/x-pack/legacy/plugins/transform/public/shim.ts deleted file mode 100644 index 9941aabcf3255e..00000000000000 --- a/x-pack/legacy/plugins/transform/public/shim.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { npStart } from 'ui/new_platform'; - -import chrome from 'ui/chrome'; -import { docTitle } from 'ui/doc_title/doc_title'; - -// @ts-ignore: allow traversal to fail on x-pack build -import { createUiStatsReporter } from '../../../../../src/legacy/core_plugins/ui_metric/public'; - -import { TRANSFORM_DOC_PATHS } from './app/constants'; - -export type NpCore = typeof npStart.core; -export type NpPlugins = typeof npStart.plugins; - -// AppCore/AppPlugins is the set of core features/plugins -// we pass on via context/hooks to the app and its components. -export type AppCore = Pick< - ShimCore, - | 'chrome' - | 'documentation' - | 'docLinks' - | 'http' - | 'i18n' - | 'injectedMetadata' - | 'savedObjects' - | 'uiSettings' - | 'overlays' - | 'notifications' ->; -export type AppPlugins = Pick<ShimPlugins, 'data' | 'management' | 'xsrfToken'>; - -export interface AppDependencies { - core: AppCore; - plugins: AppPlugins; -} - -export interface ShimCore extends NpCore { - documentation: Record< - | 'esDocBasePath' - | 'esIndicesCreateIndex' - | 'esPluginDocBasePath' - | 'esQueryDsl' - | 'esStackOverviewDocBasePath' - | 'esTransform' - | 'esTransformPivot' - | 'mlDocBasePath', - string - >; - docTitle: { - change: typeof docTitle.change; - }; -} - -export interface ShimPlugins extends NpPlugins { - uiMetric: { - createUiStatsReporter: typeof createUiStatsReporter; - }; - xsrfToken: string; -} - -export function createPublicShim(): { core: ShimCore; plugins: ShimPlugins } { - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = npStart.core.docLinks; - - return { - core: { - ...npStart.core, - documentation: { - esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`, - esIndicesCreateIndex: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/indices-create-index.html#indices-create-index`, - esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`, - esQueryDsl: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, - esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, - esTransform: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/${TRANSFORM_DOC_PATHS.transforms}`, - esTransformPivot: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/put-transform.html#put-transform-request-body`, - mlDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/`, - }, - docTitle: { - change: docTitle.change, - }, - }, - plugins: { - ...npStart.plugins, - uiMetric: { - createUiStatsReporter, - }, - xsrfToken: chrome.getXsrfToken(), - }, - }; -} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss index 3ae0ef35ee354b..8c83c0a571f28d 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss @@ -1,8 +1,2 @@ // Imported EUI @import 'src/legacy/ui/public/styles/_styling_constants'; - -// Styling within the app -@import '../../../../plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/index'; - -@import '../../../../plugins/triggers_actions_ui/public/application/sections/action_connector_form/index'; - diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index 646ea168b52a5f..0be1983477256b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -76,7 +76,7 @@ describe('config validation', () => { }).toThrowErrorMatchingInlineSnapshot(` "error validating action type config: [index]: types that failed validation: - [index.0]: expected value of type [string] but got [number] -- [index.1]: expected value to equal [null] but got [666]" +- [index.1]: expected value to equal [null]" `); }); }); @@ -150,7 +150,7 @@ describe('params validation', () => { expect(() => { validateParams(actionType, { documents: ['should be an object'] }); }).toThrowErrorMatchingInlineSnapshot( - `"error validating action params: [documents.0]: could not parse record value from [should be an object]"` + `"error validating action params: [documents.0]: could not parse record value from json input"` ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index ab860e4c3bbba8..caa183d665e09b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -137,9 +137,9 @@ describe('validateParams()', () => { validateParams(actionType, { eventAction: 'ackynollage' }); }).toThrowErrorMatchingInlineSnapshot(` "error validating action params: [eventAction]: types that failed validation: -- [eventAction.0]: expected value to equal [trigger] but got [ackynollage] -- [eventAction.1]: expected value to equal [resolve] but got [ackynollage] -- [eventAction.2]: expected value to equal [acknowledge] but got [ackynollage]" +- [eventAction.0]: expected value to equal [trigger] +- [eventAction.1]: expected value to equal [resolve] +- [eventAction.2]: expected value to equal [acknowledge]" `); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts index 6a4482f362c2b5..bb806f8ae36fc5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -63,24 +63,24 @@ describe('validateParams()', () => { validateParams(actionType, { message: 'x', level: 2 }); }).toThrowErrorMatchingInlineSnapshot(` "error validating action params: [level]: types that failed validation: -- [level.0]: expected value to equal [trace] but got [2] -- [level.1]: expected value to equal [debug] but got [2] -- [level.2]: expected value to equal [info] but got [2] -- [level.3]: expected value to equal [warn] but got [2] -- [level.4]: expected value to equal [error] but got [2] -- [level.5]: expected value to equal [fatal] but got [2]" +- [level.0]: expected value to equal [trace] +- [level.1]: expected value to equal [debug] +- [level.2]: expected value to equal [info] +- [level.3]: expected value to equal [warn] +- [level.4]: expected value to equal [error] +- [level.5]: expected value to equal [fatal]" `); expect(() => { validateParams(actionType, { message: 'x', level: 'foo' }); }).toThrowErrorMatchingInlineSnapshot(` "error validating action params: [level]: types that failed validation: -- [level.0]: expected value to equal [trace] but got [foo] -- [level.1]: expected value to equal [debug] but got [foo] -- [level.2]: expected value to equal [info] but got [foo] -- [level.3]: expected value to equal [warn] but got [foo] -- [level.4]: expected value to equal [error] but got [foo] -- [level.5]: expected value to equal [fatal] but got [foo]" +- [level.0]: expected value to equal [trace] +- [level.1]: expected value to equal [debug] +- [level.2]: expected value to equal [info] +- [level.3]: expected value to equal [warn] +- [level.4]: expected value to equal [error] +- [level.5]: expected value to equal [fatal]" `); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index e553e5c83712ac..d8f75de7818418 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -104,8 +104,8 @@ describe('config validation', () => { validateConfig(actionType, config); }).toThrowErrorMatchingInlineSnapshot(` "error validating action type config: [method]: types that failed validation: -- [method.0]: expected value to equal [post] but got [https] -- [method.1]: expected value to equal [put] but got [https]" +- [method.0]: expected value to equal [post] +- [method.1]: expected value to equal [put]" `); }); @@ -141,8 +141,8 @@ describe('config validation', () => { validateConfig(actionType, config); }).toThrowErrorMatchingInlineSnapshot(` "error validating action type config: [headers]: types that failed validation: -- [headers.0]: could not parse record value from [application/json] -- [headers.1]: expected value to equal [null] but got [application/json]" +- [headers.0]: could not parse record value from json input +- [headers.1]: expected value to equal [null]" `); }); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss new file mode 100644 index 00000000000000..2ba6f9baca90d6 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -0,0 +1,10 @@ +.auaActionWizard__selectedActionFactoryContainer { + background-color: $euiColorLightestShade; + padding: $euiSize; +} + +.auaActionWizard__actionFactoryItem { + .euiKeyPadMenuItem__label { + height: #{$euiSizeXL}; + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx new file mode 100644 index 00000000000000..62f16890cade22 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; + +storiesOf('components/ActionWizard', module) + .add('default', () => ( + <Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} /> + )) + .add('Only one factory is available', () => ( + // to make sure layout doesn't break + <Demo actionFactories={[dashboardDrilldownActionFactory]} /> + )) + .add('Long list of action factories', () => ( + // to make sure layout doesn't break + <Demo + actionFactories={[ + dashboardDrilldownActionFactory, + urlDrilldownActionFactory, + dashboardDrilldownActionFactory, + urlDrilldownActionFactory, + dashboardDrilldownActionFactory, + urlDrilldownActionFactory, + dashboardDrilldownActionFactory, + urlDrilldownActionFactory, + ]} + /> + )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx new file mode 100644 index 00000000000000..aea47be693b8fc --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; +import { + dashboardDrilldownActionFactory, + dashboards, + Demo, + urlDrilldownActionFactory, +} from './test_data'; + +// TODO: afterEach is not available for it globally during setup +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +test('Pick and configure action', () => { + const screen = render( + <Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} /> + ); + + // check that all factories are displayed to pick + expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + // change to dashboard + fireEvent.click(screen.getByText(/change/i)); + fireEvent.click(screen.getByText(/Go to Dashboard/i)); + + // Select dashboard + fireEvent.change(screen.getByLabelText(/Choose destination dashboard/i), { + target: { value: dashboards[1].id }, + }); +}); + +test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { + const screen = render(<Demo actionFactories={[urlDrilldownActionFactory]} />); + + // check that no factories are displayed to pick from + expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); + expect(screen.queryByTestId(TEST_SUBJ_SELECTED_ACTION_FACTORY)).toBeInTheDocument(); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + // check that can't change to action factory type + expect(screen.queryByTestId(/change/i)).not.toBeInTheDocument(); +}); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx new file mode 100644 index 00000000000000..41ef863c00e441 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiText, + EuiKeyPadMenuItemButton, +} from '@elastic/eui'; +import { txtChangeButton } from './i18n'; +import './action_wizard.scss'; + +// TODO: this interface is temporary for just moving forward with the component +// and it will be imported from the ../ui_actions when implemented properly +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ActionBaseConfig = {}; +export interface ActionFactory<Config extends ActionBaseConfig = ActionBaseConfig> { + type: string; // TODO: type should be tied to Action and ActionByType + displayName: string; + iconType?: string; + wizard: React.FC<ActionFactoryWizardProps<Config>>; + createConfig: () => Config; + isValid: (config: Config) => boolean; +} + +export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> { + config?: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; +} + +export interface ActionWizardProps { + /** + * List of available action factories + */ + actionFactories: Array<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> with different configs + + /** + * Currently selected action factory + * undefined - is allowed and means that non is selected + */ + currentActionFactory?: ActionFactory; + /** + * Action factory selected changed + * null - means user click "change" and removed action factory selection + */ + onActionFactoryChange: (actionFactory: ActionFactory | null) => void; + + /** + * current config for currently selected action factory + */ + config?: ActionBaseConfig; + + /** + * config changed + */ + onConfigChange: (config: ActionBaseConfig) => void; +} + +export const ActionWizard: React.FC<ActionWizardProps> = ({ + currentActionFactory, + actionFactories, + onActionFactoryChange, + onConfigChange, + config, +}) => { + // auto pick action factory if there is only 1 available + if (!currentActionFactory && actionFactories.length === 1) { + onActionFactoryChange(actionFactories[0]); + } + + if (currentActionFactory && config) { + return ( + <SelectedActionFactory + actionFactory={currentActionFactory} + showDeselect={actionFactories.length > 1} + onDeselect={() => { + onActionFactoryChange(null); + }} + config={config} + onConfigChange={newConfig => { + onConfigChange(newConfig); + }} + /> + ); + } + + return ( + <ActionFactorySelector + actionFactories={actionFactories} + onActionFactorySelected={actionFactory => { + onActionFactoryChange(actionFactory); + }} + /> + ); +}; + +interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> { + actionFactory: ActionFactory<Config>; + config: Config; + onConfigChange: (config: Config) => void; + showDeselect: boolean; + onDeselect: () => void; +} + +export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selected-action-factory'; + +const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({ + actionFactory, + onDeselect, + showDeselect, + onConfigChange, + config, +}) => { + return ( + <div + className="auaActionWizard__selectedActionFactoryContainer" + data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY} + data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY} + > + <header> + <EuiFlexGroup alignItems="center" gutterSize="s"> + {actionFactory.iconType && ( + <EuiFlexItem grow={false}> + <EuiIcon type={actionFactory.iconType} size="m" /> + </EuiFlexItem> + )} + <EuiFlexItem grow={true}> + <EuiText> + <h4>{actionFactory.displayName}</h4> + </EuiText> + </EuiFlexItem> + {showDeselect && ( + <EuiFlexItem grow={false}> + <EuiButtonEmpty size="s" onClick={() => onDeselect()}> + {txtChangeButton} + </EuiButtonEmpty> + </EuiFlexItem> + )} + </EuiFlexGroup> + </header> + <EuiSpacer size="m" /> + <div> + {actionFactory.wizard({ + config, + onConfig: onConfigChange, + })} + </div> + </div> + ); +}; + +interface ActionFactorySelectorProps { + actionFactories: ActionFactory[]; + onActionFactorySelected: (actionFactory: ActionFactory) => void; +} + +export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; + +const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({ + actionFactories, + onActionFactorySelected, +}) => { + if (actionFactories.length === 0) { + // this is not user facing, as it would be impossible to get into this state + // just leaving for dev purposes for troubleshooting + return <div>No action factories to pick from</div>; + } + + return ( + <EuiFlexGroup wrap> + {actionFactories.map(actionFactory => ( + <EuiKeyPadMenuItemButton + className="auaActionWizard__actionFactoryItem" + key={actionFactory.type} + label={actionFactory.displayName} + data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM} + data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM} + onClick={() => onActionFactorySelected(actionFactory)} + > + {actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />} + </EuiKeyPadMenuItemButton> + ))} + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts new file mode 100644 index 00000000000000..641f25176264a8 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChangeButton = i18n.translate( + 'xpack.advancedUiActions.components.actionWizard.changeButton', + { + defaultMessage: 'change', + } +); diff --git a/x-pack/legacy/plugins/transform/public/app/services/ui_metric/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts similarity index 79% rename from x-pack/legacy/plugins/transform/public/app/services/ui_metric/index.ts rename to x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index e7c3f961824e33..ed224248ec4cdf 100644 --- a/x-pack/legacy/plugins/transform/public/app/services/ui_metric/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -3,4 +3,5 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { uiMetricService } from './ui_metric'; + +export { ActionFactory, ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx new file mode 100644 index 00000000000000..8ecdde681069e8 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; +import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; + +export const dashboards = [ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, +]; + +export const dashboardDrilldownActionFactory: ActionFactory<{ + dashboardId?: string; + useCurrentDashboardFilters: boolean; + useCurrentDashboardDataRange: boolean; +}> = { + type: 'Dashboard', + displayName: 'Go to Dashboard', + iconType: 'dashboardApp', + createConfig: () => { + return { + dashboardId: undefined, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, + }; + }, + isValid: config => { + if (!config.dashboardId) return false; + return true; + }, + wizard: props => { + const config = props.config ?? { + dashboardId: undefined, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, + }; + return ( + <> + <EuiFormRow label="Choose destination dashboard:"> + <EuiSelect + name="selectDashboard" + hasNoInitialSelection={true} + options={dashboards.map(({ id, title }) => ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentFilters" + label="Use current dashboard's filters" + checked={config.useCurrentDashboardFilters} + onChange={() => + props.onConfig({ + ...config, + useCurrentDashboardFilters: !config.useCurrentDashboardFilters, + }) + } + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentDateRange" + label="Use current dashboard's date range" + checked={config.useCurrentDashboardDataRange} + onChange={() => + props.onConfig({ + ...config, + useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, + }) + } + /> + </EuiFormRow> + </> + ); + }, +}; + +export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { + type: 'Url', + displayName: 'Go to URL', + iconType: 'link', + createConfig: () => { + return { + url: '', + openInNewTab: false, + }; + }, + isValid: config => { + if (!config.url) return false; + return true; + }, + wizard: props => { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + <EuiFormRow label="Enter target URL"> + <EuiFieldText + placeholder="Enter URL" + name="url" + value={config.url} + onChange={event => props.onConfig({ ...config, url: event.target.value })} + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="openInNewTab" + label="Open in new tab?" + checked={config.openInNewTab} + onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + </EuiFormRow> + </> + ); + }, +}; + +export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) { + const [state, setState] = useState<{ + currentActionFactory?: ActionFactory; + config?: ActionBaseConfig; + }>({}); + + function changeActionFactory(newActionFactory: ActionFactory | null) { + if (!newActionFactory) { + // removing action factory + return setState({}); + } + + setState({ + currentActionFactory: newActionFactory, + config: newActionFactory.createConfig(), + }); + } + + return ( + <> + <ActionWizard + actionFactories={actionFactories} + config={state.config} + onConfigChange={newConfig => { + setState({ + ...state, + config: newConfig, + }); + }} + onActionFactoryChange={newActionFactory => { + changeActionFactory(newActionFactory); + }} + currentActionFactory={state.currentActionFactory} + /> + <div style={{ marginTop: '44px' }} /> + <hr /> + <div>Action Factory Type: {state.currentActionFactory?.type}</div> + <div>Action Factory Config: {JSON.stringify(state.config)}</div> + <div> + Is config valid:{' '} + {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + </div> + </> + ); +} diff --git a/x-pack/plugins/advanced_ui_actions/scripts/storybook.js b/x-pack/plugins/advanced_ui_actions/scripts/storybook.js new file mode 100644 index 00000000000000..3da0a3b37bfafa --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/scripts/storybook.js @@ -0,0 +1,13 @@ +/* + * 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 { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'advanced_ui_actions', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +}); diff --git a/x-pack/plugins/alerting/server/lib/parse_duration.test.ts b/x-pack/plugins/alerting/common/parse_duration.test.ts similarity index 100% rename from x-pack/plugins/alerting/server/lib/parse_duration.test.ts rename to x-pack/plugins/alerting/common/parse_duration.test.ts diff --git a/x-pack/plugins/alerting/server/lib/parse_duration.ts b/x-pack/plugins/alerting/common/parse_duration.ts similarity index 95% rename from x-pack/plugins/alerting/server/lib/parse_duration.ts rename to x-pack/plugins/alerting/common/parse_duration.ts index 51f3d746a68693..4e35a4c4cb0cf6 100644 --- a/x-pack/plugins/alerting/server/lib/parse_duration.ts +++ b/x-pack/plugins/alerting/common/parse_duration.ts @@ -8,6 +8,7 @@ const MINUTES_REGEX = /^[1-9][0-9]*m$/; const HOURS_REGEX = /^[1-9][0-9]*h$/; const DAYS_REGEX = /^[1-9][0-9]*d$/; +// parse an interval string '{digit*}{s|m|h|d}' into milliseconds export function parseDuration(duration: string): number { const parsed = parseInt(duration, 10); if (isSeconds(duration)) { diff --git a/x-pack/plugins/alerting/server/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client.test.ts index ed6e7562e3acd0..0e929ff457fbd2 100644 --- a/x-pack/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client.test.ts @@ -1984,6 +1984,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2101,6 +2102,7 @@ describe('update()', () => { "tags": Array [ "foo", ], + "throttle": null, "updatedBy": "elastic", } `); @@ -2186,6 +2188,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: '5m', actions: [ { group: 'default', @@ -2254,6 +2257,7 @@ describe('update()', () => { "tags": Array [ "foo", ], + "throttle": "5m", "updatedBy": "elastic", } `); @@ -2332,6 +2336,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: '5m', actions: [ { group: 'default', @@ -2401,6 +2406,7 @@ describe('update()', () => { "tags": Array [ "foo", ], + "throttle": "5m", "updatedBy": "elastic", } `); @@ -2441,6 +2447,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2509,6 +2516,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2613,6 +2621,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: '5m', actions: [ { group: 'default', @@ -2742,6 +2751,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2772,6 +2782,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2808,6 +2819,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', @@ -2843,6 +2855,7 @@ describe('update()', () => { params: { bar: true, }, + throttle: null, actions: [ { group: 'default', diff --git a/x-pack/plugins/alerting/server/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client.ts index 9a56781aa1d7d8..49c80af0072c98 100644 --- a/x-pack/plugins/alerting/server/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client.ts @@ -107,6 +107,7 @@ interface UpdateOptions { schedule: IntervalSchedule; actions: NormalizedAlertAction[]; params: Record<string, any>; + throttle: string | null; }; } diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index c84825cadbd16f..2f610aafd8c31f 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { parseDuration, validateDurationSchema } from './parse_duration'; +export { parseDuration, validateDurationSchema } from '../../common/parse_duration'; export { LicenseState } from './license_state'; export { validateAlertTypeParams } from './validate_alert_type_params'; diff --git a/x-pack/plugins/alerting/server/routes/update.test.ts b/x-pack/plugins/alerting/server/routes/update.test.ts index 005367ef7979c9..c3628617f861fe 100644 --- a/x-pack/plugins/alerting/server/routes/update.test.ts +++ b/x-pack/plugins/alerting/server/routes/update.test.ts @@ -116,6 +116,7 @@ describe('updateAlertRoute', () => { "tags": Array [ "bar", ], + "throttle": null, }, "id": "1", }, diff --git a/x-pack/plugins/alerting/server/routes/update.ts b/x-pack/plugins/alerting/server/routes/update.ts index 76b864a51aec6d..087fec2207284f 100644 --- a/x-pack/plugins/alerting/server/routes/update.ts +++ b/x-pack/plugins/alerting/server/routes/update.ts @@ -62,11 +62,11 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - const { name, actions, params, schedule, tags } = req.body; + const { name, actions, params, schedule, tags, throttle } = req.body; return res.ok({ body: await alertsClient.update({ id, - data: { name, actions, params, schedule, tags }, + data: { name, actions, params, schedule, tags, throttle }, }), }); }) diff --git a/x-pack/plugins/alerting_builtins/common/alert_types/index_threshold/index.ts b/x-pack/plugins/alerting_builtins/common/alert_types/index_threshold/index.ts new file mode 100644 index 00000000000000..63873918b02313 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/common/alert_types/index_threshold/index.ts @@ -0,0 +1,16 @@ +/* + * 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 interface TimeSeriesResult { + results: TimeSeriesResultRow[]; +} + +export interface TimeSeriesResultRow { + group: string; + metrics: MetricResult[]; +} + +export type MetricResult = [string, number]; // [iso date, value] diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts index d67d29cacde423..109785b835bdfd 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts @@ -64,15 +64,15 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.index = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` "[index]: types that failed validation: -- [index.0]: value is [] but it must have a minimum length of [1]. -- [index.1]: could not parse array value from []" +- [index.0]: value has length [0] but it must have a minimum length of [1]. +- [index.1]: could not parse array value from json input" `); params.index = ['', 'a']; expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` "[index]: types that failed validation: - [index.0]: expected value of type [string] but got [Array] -- [index.1.0]: value is [] but it must have a minimum length of [1]." +- [index.1.0]: value has length [0] but it must have a minimum length of [1]." `); }); @@ -89,7 +89,7 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.timeField = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[timeField]: value is [] but it must have a minimum length of [1]."` + `"[timeField]: value has length [0] but it must have a minimum length of [1]."` ); }); @@ -113,7 +113,7 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.aggField = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[aggField]: value is [] but it must have a minimum length of [1]."` + `"[aggField]: value has length [0] but it must have a minimum length of [1]."` ); }); @@ -126,7 +126,7 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.termField = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[termField]: value is [] but it must have a minimum length of [1]."` + `"[termField]: value has length [0] but it must have a minimum length of [1]."` ); }); @@ -145,7 +145,7 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.termSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[termSize]: Value is [0] but it must be equal to or greater than [1]."` + `"[termSize]: Value must be equal to or greater than [1]."` ); }); @@ -157,7 +157,7 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a params.timeWindowSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[timeWindowSize]: Value is [0] but it must be equal to or greater than [1]."` + `"[timeWindowSize]: Value must be equal to or greater than [1]."` ); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts index 6cb21a1581113f..abe5d562027eba 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts @@ -18,19 +18,11 @@ import { getDateStartAfterDateEndErrorMessage, } from './date_range_info'; -// The result is an object with a key for every field value aggregated -// via the `aggField` property. If `aggField` is not specified, the -// object will have a single key of `all documents`. The value associated -// with each key is an array of 2-tuples of `[ ISO-date, calculated-value ]` - -export interface TimeSeriesResult { - results: TimeSeriesResultRow[]; -} -export interface TimeSeriesResultRow { - group: string; - metrics: MetricResult[]; -} -export type MetricResult = [string, number]; // [iso date, value] +export { + TimeSeriesResult, + TimeSeriesResultRow, + MetricResult, +} from '../../../../common/alert_types/index_threshold'; // The parameters here are very similar to the alert parameters. // Missing are `comparator` and `threshold`, which aren't needed to generate diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts index 3ac47004279b31..ef1934235807b8 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/__tests__/get_buckets.test.ts @@ -53,7 +53,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'apm-*', 'apm_oss.transactionIndices': 'apm-*', 'apm_oss.metricsIndices': 'apm-*', - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts new file mode 100644 index 00000000000000..0a0da332e73aec --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -0,0 +1,91 @@ +/* + * 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 { IClusterClient, Logger } from 'src/core/server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; + +export type Mappings = + | { + dynamic?: boolean; + properties: Record<string, Mappings>; + } + | { + type: string; + ignore_above?: number; + scaling_factor?: number; + ignore_malformed?: boolean; + coerce?: boolean; + }; + +export async function createOrUpdateIndex({ + index, + mappings, + esClient, + logger +}: { + index: string; + mappings: Mappings; + esClient: IClusterClient; + logger: Logger; +}) { + try { + const { callAsInternalUser } = esClient; + const indexExists = await callAsInternalUser('indices.exists', { index }); + const result = indexExists + ? await updateExistingIndex({ + index, + callAsInternalUser, + mappings + }) + : await createNewIndex({ + index, + callAsInternalUser, + mappings + }); + + if (!result.acknowledged) { + const resultError = + result && result.error && JSON.stringify(result.error); + throw new Error(resultError); + } + } catch (e) { + logger.error(`Could not create APM index: '${index}'. Error: ${e.message}`); + } +} + +function createNewIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.create', { + index, + body: { + // auto_expand_replicas: Allows cluster to not have replicas for this index + settings: { 'index.auto_expand_replicas': '0-1' }, + mappings + } + }); +} + +function updateExistingIndex({ + index, + callAsInternalUser, + mappings +}: { + index: string; + callAsInternalUser: CallCluster; + mappings: Mappings; +}) { + return callAsInternalUser('indices.putMapping', { + index, + body: mappings + }); +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index 8cfb7e7edb4c69..bc03138e0c2475 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -5,8 +5,11 @@ */ import { IClusterClient, Logger } from 'src/core/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; export async function createApmAgentConfigurationIndex({ @@ -18,87 +21,54 @@ export async function createApmAgentConfigurationIndex({ config: APMConfig; logger: Logger; }) { - try { - const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; - const { callAsInternalUser } = esClient; - const indexExists = await callAsInternalUser('indices.exists', { index }); - const result = indexExists - ? await updateExistingIndex(index, callAsInternalUser) - : await createNewIndex(index, callAsInternalUser); - - if (!result.acknowledged) { - const resultError = - result && result.error && JSON.stringify(result.error); - throw new Error( - `Unable to create APM Agent Configuration index '${index}': ${resultError}` - ); - } - } catch (e) { - logger.error(`Could not create APM Agent configuration: ${e.message}`); - } + const index = getApmIndicesConfig(config).apmAgentConfigurationIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); } -function createNewIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.create', { - index, - body: { - settings: { 'index.auto_expand_replicas': '0-1' }, - mappings: { properties: mappingProperties } - } - }); -} - -// Necessary for migration reasons -// Added in 7.5: `capture_body`, `transaction_max_spans`, `applied_by_agent`, `agent_name` and `etag` -function updateExistingIndex(index: string, callWithInternalUser: CallCluster) { - return callWithInternalUser('indices.putMapping', { - index, - body: { properties: mappingProperties } - }); -} - -const mappingProperties = { - '@timestamp': { - type: 'date' - }, - service: { - properties: { - name: { - type: 'keyword', - ignore_above: 1024 - }, - environment: { - type: 'keyword', - ignore_above: 1024 +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + service: { + properties: { + name: { + type: 'keyword', + ignore_above: 1024 + }, + environment: { + type: 'keyword', + ignore_above: 1024 + } } - } - }, - settings: { - properties: { - transaction_sample_rate: { - type: 'scaled_float', - scaling_factor: 1000, - ignore_malformed: true, - coerce: false - }, - capture_body: { - type: 'keyword', - ignore_above: 1024 - }, - transaction_max_spans: { - type: 'short' + }, + settings: { + properties: { + transaction_sample_rate: { + type: 'scaled_float', + scaling_factor: 1000, + ignore_malformed: true, + coerce: false + }, + capture_body: { + type: 'keyword', + ignore_above: 1024 + }, + transaction_max_spans: { + type: 'short' + } } + }, + applied_by_agent: { + type: 'boolean' + }, + agent_name: { + type: 'keyword', + ignore_above: 1024 + }, + etag: { + type: 'keyword', + ignore_above: 1024 } - }, - applied_by_agent: { - type: 'boolean' - }, - agent_name: { - type: 'keyword', - ignore_above: 1024 - }, - etag: { - type: 'keyword', - ignore_above: 1024 } }; diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index 00493e53f06dd4..f338ee058842c8 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -25,6 +25,7 @@ export interface ApmIndicesConfig { 'apm_oss.transactionIndices': string; 'apm_oss.metricsIndices': string; apmAgentConfigurationIndex: string; + apmCustomLinkIndex: string; } export type ApmIndicesName = keyof ApmIndicesConfig; @@ -52,7 +53,8 @@ export function getApmIndicesConfig(config: APMConfig): ApmIndicesConfig { 'apm_oss.transactionIndices': config['apm_oss.transactionIndices'], 'apm_oss.metricsIndices': config['apm_oss.metricsIndices'], // system indices, not configurable - apmAgentConfigurationIndex: '.apm-agent-configuration' + apmAgentConfigurationIndex: '.apm-agent-configuration', + apmCustomLinkIndex: '.apm-custom-link' }; } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap new file mode 100644 index 00000000000000..b3819ace40d6cb --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`List Custom Links fetches all custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; + +exports[`List Custom Links filters custom links 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.name", + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "term": Object { + "transaction.name": "bar", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "transaction.name", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 500, +} +`; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts new file mode 100644 index 00000000000000..624f01c649322f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/create_or_update_custom_link.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { createOrUpdateCustomLink } from '../create_or_update_custom_link'; +import { CustomLink } from '../custom_link_types'; +import { Setup } from '../../../helpers/setup_request'; +import { mockNow } from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; + +describe('Create or Update Custom link', () => { + const internalClientIndexMock = jest.fn(); + const mockedSetup = ({ + internalClient: { + index: internalClientIndexMock + }, + indices: { + apmCustomLinkIndex: 'apmCustomLinkIndex' + } + } as unknown) as Setup; + + const customLink = ({ + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } as unknown) as CustomLink; + afterEach(() => { + internalClientIndexMock.mockClear(); + }); + + beforeAll(() => { + mockNow(1570737000000); + }); + + it('creates a new custom link', () => { + createOrUpdateCustomLink({ customLink, setup: mockedSetup }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); + it('update a new custom link', () => { + createOrUpdateCustomLink({ + customLinkId: 'bar', + customLink, + setup: mockedSetup + }); + expect(internalClientIndexMock).toHaveBeenCalledWith({ + refresh: true, + index: 'apmCustomLinkIndex', + id: 'bar', + body: { + '@timestamp': 1570737000000, + label: 'foo', + url: 'http://elastic.com/{{trace.id}}', + 'service.name': 'opbeans-java', + 'transaction.type': 'Request' + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts new file mode 100644 index 00000000000000..5466225dc3211d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/list_custom_links.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { listCustomLinks } from '../list_custom_links'; +import { + inspectSearchParams, + SearchParamsMock +} from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { Setup } from '../../../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_NAME +} from '../../../../../common/elasticsearch_fieldnames'; + +describe('List Custom Links', () => { + let mock: SearchParamsMock; + + it('fetches all custom links', async () => { + mock = await inspectSearchParams(setup => + listCustomLinks({ + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + + it('filters custom links', async () => { + const filters = { + [SERVICE_NAME]: 'foo', + [TRANSACTION_NAME]: 'bar' + }; + mock = await inspectSearchParams(setup => + listCustomLinks({ + filters, + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts new file mode 100644 index 00000000000000..cdb3cff616030a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, Logger } from 'src/core/server'; +import { APMConfig } from '../../..'; +import { + createOrUpdateIndex, + Mappings +} from '../../helpers/create_or_update_index'; +import { getApmIndicesConfig } from '../apm_indices/get_apm_indices'; + +export const createApmCustomLinkIndex = async ({ + esClient, + config, + logger +}: { + esClient: IClusterClient; + config: APMConfig; + logger: Logger; +}) => { + const index = getApmIndicesConfig(config).apmCustomLinkIndex; + return createOrUpdateIndex({ index, esClient, logger, mappings }); +}; + +const mappings: Mappings = { + properties: { + '@timestamp': { + type: 'date' + }, + label: { + type: 'text' + }, + url: { + type: 'keyword' + }, + service: { + properties: { + name: { + type: 'keyword' + }, + environment: { + type: 'keyword' + } + } + }, + transaction: { + properties: { + name: { + type: 'keyword' + }, + type: { + type: 'keyword' + } + } + } + } +}; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts new file mode 100644 index 00000000000000..809fe2050a0728 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -0,0 +1,41 @@ +/* + * 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 { pick } from 'lodash'; +import { filterOptions } from '../../../routes/settings/custom_link'; +import { APMIndexDocumentParams } from '../../helpers/es_client'; +import { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; + +export async function createOrUpdateCustomLink({ + customLinkId, + customLink, + setup +}: { + customLinkId?: string; + customLink: Omit<CustomLink, '@timestamp'>; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params: APMIndexDocumentParams<CustomLink> = { + refresh: true, + index: indices.apmCustomLinkIndex, + body: { + '@timestamp': Date.now(), + label: customLink.label, + url: customLink.url, + ...pick(customLink, filterOptions) + } + }; + + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (customLinkId) { + params.id = customLinkId; + } + + return internalClient.index(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts new file mode 100644 index 00000000000000..60b97712713a92 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts @@ -0,0 +1,14 @@ +/* + * 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 * as t from 'io-ts'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export type CustomLink = { + id?: string; + '@timestamp': number; + label: string; + url: string; +} & FilterOptions; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts new file mode 100644 index 00000000000000..2f3ea0940cb26f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; + +export async function deleteCustomLink({ + customLinkId, + setup +}: { + customLinkId: string; + setup: Setup; +}) { + const { internalClient, indices } = setup; + + const params = { + refresh: 'wait_for', + index: indices.apmCustomLinkIndex, + id: customLinkId + }; + + return internalClient.delete(params); +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts new file mode 100644 index 00000000000000..e6052da73b0dbc --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -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 { Setup } from '../../helpers/setup_request'; +import { CustomLink } from './custom_link_types'; +import { FilterOptions } from '../../../routes/settings/custom_link'; + +export async function listCustomLinks({ + setup, + filters = {} +}: { + setup: Setup; + filters?: FilterOptions; +}) { + const { internalClient, indices } = setup; + + const esFilters = Object.entries(filters).map(([key, value]) => { + return { + bool: { + minimum_should_match: 1, + should: [ + { term: { [key]: value } }, + { bool: { must_not: [{ exists: { field: key } }] } } + ] + } + }; + }); + + const params = { + index: indices.apmCustomLinkIndex, + size: 500, + body: { + query: { + bool: { + filter: esFilters + } + } + } + }; + const resp = await internalClient.search<CustomLink>(params); + return resp.hits.hits.map(item => ({ + id: item._id, + ...item._source + })); +} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts index c4a0be0f48c145..02bf60d3605bd9 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts @@ -28,7 +28,8 @@ function getSetup() { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any }; diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts index 9ab31be9f7219f..5e443b92aa91ae 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.test.ts @@ -17,7 +17,8 @@ const mockIndices = { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }; function getMockSetup(esResponse: any) { diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index cc8fabe33e63dc..7a3277965ef8e0 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -42,7 +42,8 @@ describe('getAnomalySeries', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts index 1970e39a2752ef..a87a277eb0c0e4 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.test.ts @@ -41,7 +41,8 @@ describe('timeseriesFetcher', () => { 'apm_oss.spanIndices': 'myIndex', 'apm_oss.transactionIndices': 'myIndex', 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex' + apmAgentConfigurationIndex: 'myIndex', + apmCustomLinkIndex: 'myIndex' }, dynamicIndexPattern: null as any } diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 773f0d4e6fac56..db14730f802a96 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; +import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; @@ -66,6 +67,12 @@ export class APMPlugin implements Plugin<APMPluginContract> { config: currentConfig, logger }); + // create custom action index without blocking setup lifecycle + createApmCustomLinkIndex({ + esClient: core.elasticsearch.dataClient, + config: currentConfig, + logger + }); plugins.home.tutorials.registerTutorial( tutorialProvider({ diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 21392edbb2c48f..34f0536a90b4d8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -59,6 +59,12 @@ import { import { createApi } from './create_api'; import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; import { indicesPrivilegesRoute } from './security'; +import { + createCustomLinkRoute, + updateCustomLinkRoute, + deleteCustomLinkRoute, + listCustomLinksRoute +} from './settings/custom_link'; const createApmApi = () => { const api = createApi() @@ -126,7 +132,13 @@ const createApmApi = () => { .add(serviceMapServiceNodeRoute) // security - .add(indicesPrivilegesRoute); + .add(indicesPrivilegesRoute) + + // Custom links + .add(createCustomLinkRoute) + .add(updateCustomLinkRoute) + .add(deleteCustomLinkRoute) + .add(listCustomLinksRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts new file mode 100644 index 00000000000000..5988d7f85b186e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -0,0 +1,117 @@ +/* + * 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 * as t from 'io-ts'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_NAME, + TRANSACTION_TYPE +} from '../../../common/elasticsearch_fieldnames'; +import { createRoute } from '../create_route'; +import { setupRequest } from '../../lib/helpers/setup_request'; +import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; +import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; +import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; + +const FilterOptionsRt = t.partial({ + [SERVICE_NAME]: t.string, + [SERVICE_ENVIRONMENT]: t.string, + [TRANSACTION_NAME]: t.string, + [TRANSACTION_TYPE]: t.string +}); + +export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; + +export const filterOptions: Array<keyof FilterOptions> = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +]; + +export const listCustomLinksRoute = createRoute(core => ({ + path: '/api/apm/settings/custom_links', + params: { + query: FilterOptionsRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + return await listCustomLinks({ setup, filters: params.query }); + } +})); + +const payload = t.intersection([ + t.type({ + label: t.string, + url: t.string + }), + FilterOptionsRt +]); + +export const createCustomLinkRoute = createRoute(() => ({ + method: 'POST', + path: '/api/apm/settings/custom_links', + params: { + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ customLink, setup }); + return res; + } +})); + +export const updateCustomLinkRoute = createRoute(() => ({ + method: 'PUT', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }), + body: payload + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const customLink = context.params.body; + const res = await createOrUpdateCustomLink({ + customLinkId: id, + customLink, + setup + }); + return res; + } +})); + +export const deleteCustomLinkRoute = createRoute(() => ({ + method: 'DELETE', + path: '/api/apm/settings/custom_links/{id}', + params: { + path: t.type({ + id: t.string + }) + }, + options: { + tags: ['access:apm', 'access:apm_write'] + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { id } = context.params.path; + const res = await deleteCustomLink({ + customLinkId: id, + setup + }); + return res; + } +})); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts new file mode 100644 index 00000000000000..1bf39e66164809 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { CommentResponseRt } from './comment'; +import { UserRT } from '../user'; + +const CaseBasicRt = rt.type({ + description: rt.string, + state: rt.union([rt.literal('open'), rt.literal('closed')]), + tags: rt.array(rt.string), + title: rt.string, +}); + +export const CaseAttributesRt = rt.intersection([ + CaseBasicRt, + rt.type({ + comment_ids: rt.array(rt.string), + created_at: rt.string, + created_by: UserRT, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const CaseRequestRt = CaseBasicRt; + +export const CaseResponseRt = rt.intersection([ + CaseAttributesRt, + rt.type({ + id: rt.string, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const CasesResponseRt = rt.type({ + cases: rt.array(CaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + +export const CasePatchRequestRt = rt.intersection([ + rt.partial(CaseRequestRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>; +export type CaseRequest = rt.TypeOf<typeof CaseRequestRt>; +export type CaseResponse = rt.TypeOf<typeof CaseResponseRt>; +export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>; +export type CasePatchRequest = rt.TypeOf<typeof CasePatchRequestRt>; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts new file mode 100644 index 00000000000000..cebfa00425728b --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -0,0 +1,56 @@ +/* + * 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 * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +const CommentBasicRt = rt.type({ + comment: rt.string, +}); + +export const CommentAttributesRt = rt.intersection([ + CommentBasicRt, + rt.type({ + created_at: rt.string, + created_by: UserRT, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const CommentRequestRt = CommentBasicRt; + +export const CommentResponseRt = rt.intersection([ + CommentAttributesRt, + rt.type({ + id: rt.string, + version: rt.string, + }), +]); + +export const AllCommentsResponseRT = rt.array(CommentResponseRt); + +export const CommentPatchRequestRt = rt.intersection([ + rt.partial(CommentRequestRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const CommentsResponseRt = rt.type({ + comments: rt.array(CommentResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + +export const AllCommentsResponseRt = rt.array(CommentResponseRt); + +export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>; +export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>; +export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>; +export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>; +export type CommentsResponse = rt.TypeOf<typeof CommentsResponseRt>; +export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.js b/x-pack/plugins/case/common/api/cases/index.ts similarity index 82% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.js rename to x-pack/plugins/case/common/api/cases/index.ts index 4f2cce58614243..83e249e3257c47 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.js +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { TimeBuckets } from './time_buckets'; +export * from './case'; +export * from './comment'; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/common/api/index.ts similarity index 67% rename from x-pack/plugins/case/server/constants.ts rename to x-pack/plugins/case/common/api/index.ts index 276dcd135254a3..3e94d91569ca58 100644 --- a/x-pack/plugins/case/server/constants.ts +++ b/x-pack/plugins/case/common/api/index.ts @@ -4,5 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const CASE_SAVED_OBJECT = 'case-workflow'; -export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; +export * from './cases'; +export * from './runtime_types'; +export * from './saved_object'; diff --git a/x-pack/plugins/case/common/api/runtime_types.ts b/x-pack/plugins/case/common/api/runtime_types.ts new file mode 100644 index 00000000000000..d5b858df38def1 --- /dev/null +++ b/x-pack/plugins/case/common/api/runtime_types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { Errors, Type } from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; + +type ErrorFactory = (message: string) => Error; + +export const createPlainError = (message: string) => new Error(message); + +export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => { + throw createError(failure(errors).join('\n')); +}; + +export const decodeOrThrow = <A, O, I>( + runtimeType: Type<A, O, I>, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts new file mode 100644 index 00000000000000..0da859649a34e4 --- /dev/null +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { either } from 'fp-ts/lib/Either'; + +const NumberFromString = new rt.Type<number, string, unknown>( + 'NumberFromString', + rt.number.is, + (u, c) => + either.chain(rt.string.validate(u, c), s => { + const n = +s; + return isNaN(n) ? rt.failure(u, c, 'cannot parse to a number') : rt.success(n); + }), + String +); + +export const SavedObjectFindOptionsRt = rt.partial({ + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + filter: rt.string, + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export type SavedObjectFindOptions = rt.TypeOf<typeof SavedObjectFindOptionsRt>; diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts new file mode 100644 index 00000000000000..bf5cde7af03f3f --- /dev/null +++ b/x-pack/plugins/case/common/api/user.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const UserRT = rt.type({ + full_name: rt.union([rt.undefined, rt.string, rt.null]), + username: rt.union([rt.string, rt.null]), +}); diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json index 23e3cc789ad3b3..4a0151546c8fb3 100644 --- a/x-pack/plugins/case/kibana.json +++ b/x-pack/plugins/case/kibana.json @@ -3,6 +3,10 @@ "id": "case", "kibanaVersion": "kibana", "requiredPlugins": ["security"], + "optionalPlugins": [ + "spaces", + "security" + ], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index 990aef19b74f77..f924810baa9126 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -7,7 +7,6 @@ import { PluginInitializerContext } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { CasePlugin } from './plugin'; -export { CaseAttributes, CommentAttributes } from './routes/api/types'; export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5ca640f0b25c38..7ce3a61f037796 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,11 +5,15 @@ */ import { first, map } from 'rxjs/operators'; -import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { Logger, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup } from 'src/core/server'; + +import { SecurityPluginSetup } from '../../security/server'; + import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; +import { caseSavedObjectType, caseCommentSavedObjectType } from './saved_object_types'; import { CaseService } from './services'; -import { SecurityPluginSetup } from '../../security/server'; function createConfig$(context: PluginInitializerContext) { return context.config.create<ConfigType>().pipe(map(config => config)); @@ -35,6 +39,9 @@ export class CasePlugin { return; } + core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(caseCommentSavedObjectType); + const service = new CaseService(this.log); this.log.debug( diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index eb9afb27a749e2..7c97adc1b31bfa 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -5,12 +5,26 @@ */ import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; -export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../saved_object_types'; + +export const createMockSavedObjectsRepository = ({ + caseSavedObject = [], + caseCommentSavedObject = [], +}: { + caseSavedObject?: any[]; + caseCommentSavedObject?: any[]; +}) => { const mockSavedObjectsClientContract = ({ get: jest.fn((type, id) => { - const result = savedObject.filter(s => s.id === id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + const result = caseCommentSavedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } + const result = caseSavedObject.filter(s => s.id === id); if (!result.length) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -20,11 +34,20 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + + if (findArgs.type === CASE_COMMENT_SAVED_OBJECT) { + return { + page: 1, + per_page: 5, + total: caseCommentSavedObject.length, + saved_objects: caseCommentSavedObject, + }; + } return { page: 1, per_page: 5, - total: savedObject.length, - saved_objects: savedObject, + total: caseSavedObject.length, + saved_objects: caseSavedObject, }; }), create: jest.fn((type, attributes, references) => { @@ -51,9 +74,16 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { }; }), update: jest.fn((type, id, attributes) => { - if (!savedObject.find(s => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + if (!caseCommentSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + } else if (type === CASE_SAVED_OBJECT) { + if (!caseSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } } + return { id, type, @@ -63,13 +93,17 @@ export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { }; }), delete: jest.fn((type: string, id: string) => { - const result = savedObject.filter(s => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + let result = caseSavedObject.filter(s => s.id === id); + if (type === CASE_COMMENT_SAVED_OBJECT) { + result = caseCommentSavedObject.filter(s => s.id === id); } - if (type === 'case-workflow-comment' && id === 'bad-guy') { + if (type === CASE_COMMENT_SAVED_OBJECT && id === 'bad-guy') { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; }), deleteByNamespace: jest.fn(), diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index ac9eddd6dd2cb6..32348fecba1bef 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -8,7 +8,7 @@ import { IRouter } from 'kibana/server'; import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; import { CaseService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; -import { RouteDeps } from '../index'; +import { RouteDeps } from '../types'; export const createRoute = async ( api: (deps: RouteDeps) => void, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index c7f6b6fad7d1aa..3701e4f14e8b38 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -4,11 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export const mockCases = [ +import { SavedObject } from 'kibana/server'; +import { CaseAttributes, CommentAttributes } from '../../../../common/api'; + +export const mockCases: Array<SavedObject<CaseAttributes>> = [ { - type: 'case-workflow', + type: 'cases', id: 'mock-id-1', attributes: { + comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -19,15 +23,20 @@ export const mockCases = [ state: 'open', tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', version: 'WzAsMV0=', }, { - type: 'case-workflow', + type: 'cases', id: 'mock-id-2', attributes: { + comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -38,15 +47,20 @@ export const mockCases = [ state: 'open', tags: ['Data Destruction'], updated_at: '2019-11-25T22:32:00.900Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', version: 'WzQsMV0=', }, { - type: 'case-workflow', + type: 'cases', id: 'mock-id-3', attributes: { + comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -57,6 +71,10 @@ export const mockCases = [ state: 'open', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -73,9 +91,9 @@ export const mockCasesErrorTriggerData = [ }, ]; -export const mockCaseComments = [ +export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [ { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-1', attributes: { comment: 'Wow, good luck catching that bad meanie!', @@ -85,11 +103,15 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-1', }, ], @@ -97,7 +119,7 @@ export const mockCaseComments = [ version: 'WzEsMV0=', }, { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-2', attributes: { comment: 'Well I decided to update my comment. So what? Deal with it.', @@ -107,19 +129,24 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-1', }, ], updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', }, { - type: 'case-workflow-comment', + type: 'cases-comment', id: 'mock-comment-3', attributes: { comment: 'Wow, good luck catching that bad meanie!', @@ -129,15 +156,20 @@ export const mockCaseComments = [ username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', + updated_by: { + full_name: 'elastic', + username: 'elastic', + }, }, references: [ { - type: 'case-workflow', - name: 'associated-case-workflow', + type: 'cases', + name: 'associated-cases', id: 'mock-id-3', }, ], updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', }, ]; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts new file mode 100644 index 00000000000000..00d06bfdd26775 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -0,0 +1,55 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const comments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + await Promise.all( + comments.saved_objects.map(comment => + caseService.deleteComment({ + client, + commentId: comment.id, + }) + ) + ); + + const updateCase = { + comment_ids: [], + }; + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts similarity index 61% rename from x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts index e50b3cbaa9c9a1..8f05fbce391f86 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts @@ -4,50 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, - mockCasesErrorTriggerData, -} from '../__fixtures__'; -import { initDeleteCommentApi } from '../delete_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCaseComments, +} from '../../__fixtures__'; +import { initDeleteCommentApi } from './delete_comment'; describe('DELETE comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { routeHandler = await createRoute(initDeleteCommentApi, 'delete'); }); - it(`deletes the comment. responds with 204`, async () => { + it(`deletes the comment. responds with 200`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{comment_id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'delete', params: { - comment_id: 'mock-id-1', + case_id: 'mock-id-1', + comment_id: 'mock-comment-1', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(204); + expect(response.status).toEqual(200); }); it(`returns an error when thrown from deleteComment service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{comment_id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'delete', params: { + case_id: 'mock-id-1', comment_id: 'bad-guy', }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(400); + expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts new file mode 100644 index 00000000000000..85c4701f82e1de --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -0,0 +1,61 @@ +/* + * 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 Boom from 'boom'; +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{case_id}/comments/{comment_id}', + validate: { + params: schema.object({ + case_id: schema.string(), + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + throw Boom.notFound( + `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + + const updateCase = { + comment_ids: myCase.attributes.comment_ids.filter( + cId => cId !== request.params.comment_id + ), + }; + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts new file mode 100644 index 00000000000000..dcf70d0d9819cf --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -0,0 +1,61 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CommentsResponseRt, + SavedObjectFindOptionsRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformComments, wrapError } from '../../utils'; + +export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/comments/_find', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + SavedObjectFindOptionsRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const args = query + ? { + client: context.core.savedObjects.client, + caseId: request.params.case_id, + options: { + ...query, + sortField: 'created_at', + }, + } + : { + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }; + + const theComments = await caseService.getAllCaseComments(args); + return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts similarity index 51% rename from x-pack/plugins/case/server/routes/api/get_comment.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index d892b4cfebc3b8..65f2de71252363 100644 --- a/x-pack/plugins/case/server/routes/api/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -5,26 +5,30 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { flattenCommentSavedObject, wrapError } from './utils'; -export function initGetCommentApi({ caseService, router }: RouteDeps) { +import { AllCommentsResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenCommentSavedObjects, wrapError } from '../../utils'; + +export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments', validate: { params: schema.object({ - id: schema.string(), + case_id: schema.string(), }), }, }, async (context, request, response) => { try { - const theComment = await caseService.getComment({ + const comments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, - commentId: request.params.id, + caseId: request.params.case_id, + }); + return response.ok({ + body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); - return response.ok({ body: flattenCommentSavedObject(theComment) }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts similarity index 53% rename from x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts index 3add93acc641f1..9c8d0e5254df0a 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts @@ -3,18 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCaseComments, -} from '../__fixtures__'; -import { initGetCommentApi } from '../get_comment'; -import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; -import { flattenCommentSavedObject } from '../utils'; -import { CommentAttributes } from '../types'; + mockCases, +} from '../../__fixtures__'; +import { flattenCommentSavedObject } from '../../utils'; +import { initGetCommentApi } from './get_comment'; describe('GET comment', () => { let routeHandler: RequestHandler<any, any, any>; @@ -23,33 +23,44 @@ describe('GET comment', () => { }); it(`returns the comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'get', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', + comment_id: 'mock-comment-1', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCommentSavedObject( - mockCaseComments.find(s => s.id === 'mock-comment-1') as SavedObject<CommentAttributes> - ) - ); + const myPayload = mockCaseComments.find(s => s.id === 'mock-comment-1'); + expect(myPayload).not.toBeUndefined(); + if (myPayload != null) { + expect(response.payload).toEqual(flattenCommentSavedObject(myPayload)); + } }); it(`returns an error when getComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comments/{id}', + path: '/api/cases/{case_id}/comments/{comment_id}', method: 'get', params: { - id: 'not-real', + case_id: 'mock-id-1', + comment_id: 'not-real', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts new file mode 100644 index 00000000000000..06619abae84879 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; + +import { CommentResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenCommentSavedObject, wrapError } from '../../utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/comments/{comment_id}', + validate: { + params: schema.object({ + case_id: schema.string(), + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const myCase = await caseService.getCase({ + client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + throw Boom.notFound( + `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + const comment = await caseService.getComment({ + client, + commentId: request.params.comment_id, + }); + return response.ok({ + body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts similarity index 64% rename from x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 6b4e3c194eb823..4e7e266f326a24 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -3,72 +3,93 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCaseComments, -} from '../__fixtures__'; -import { initUpdateCommentApi } from '../update_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCases, +} from '../../__fixtures__'; +import { initPatchCommentApi } from './patch_comment'; -describe('UPDATE comment', () => { +describe('PATCH comment', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCommentApi, 'patch'); + routeHandler = await createRoute(initPatchCommentApi, 'patch'); }); - it(`Updates a comment`, async () => { + it(`Patch a comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', }, body: { comment: 'Update my comment', + id: 'mock-comment-1', version: 'WzEsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comment).toEqual('Update my comment'); }); + it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-1', + case_id: 'mock-id-1', }, body: { + id: 'mock-comment-1', comment: 'Update my comment', version: 'badv=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/comment/{id}', + path: '/api/cases/{case_id}/comments', method: 'patch', params: { - id: 'mock-comment-does-not-exist', + case_id: 'mock-id-1', }, body: { comment: 'Update my comment', + id: 'mock-comment-does-not-exist', + version: 'WzEsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts new file mode 100644 index 00000000000000..f1568f22c6c99f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -0,0 +1,84 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; + +import { RouteDeps } from '../../types'; +import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; + +export function initPatchCommentApi({ caseService, router }: RouteDeps) { + router.patch( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CommentPatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + if (!myCase.attributes.comment_ids.includes(query.id)) { + throw Boom.notFound( + `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + ); + } + + const myComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: query.id, + }); + + if (query.version !== myComment.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + + const updatedBy = await caseService.getUser({ request, response }); + const { full_name, username } = updatedBy; + const updatedComment = await caseService.patchComment({ + client: context.core.savedObjects.client, + commentId: query.id, + updatedAttributes: { + ...query, + updated_at: new Date().toISOString(), + updated_by: { full_name, username }, + }, + }); + + return response.ok({ + body: CommentResponseRt.encode( + flattenCommentSavedObject({ + ...updatedComment, + attributes: { ...myComment.attributes, ...updatedComment.attributes }, + references: myComment.references, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts similarity index 66% rename from x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts rename to x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 653140af2a7cfa..e51ec7c894d08b 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, -} from '../__fixtures__'; -import { initPostCommentApi } from '../post_comment'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; + mockCaseComments, +} from '../../__fixtures__'; +import { initPostCommentApi } from './post_comment'; describe('POST comment', () => { let routeHandler: RequestHandler<any, any, any>; @@ -21,35 +23,45 @@ describe('POST comment', () => { }); it(`Posts a new comment`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comment_id).toEqual('mock-comment'); + expect(response.payload.id).toEqual('mock-comment'); }); it(`Returns an error if the case does not exist`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'this-is-not-real', + case_id: 'this-is-not-real', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -57,17 +69,22 @@ describe('POST comment', () => { }); it(`Returns an error if postNewCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Throw an error', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -77,17 +94,22 @@ describe('POST comment', () => { routeHandler = await createRoute(initPostCommentApi, 'post', true); const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}/comment', + path: '/api/cases/{case_id}/comments', method: 'post', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, body: { comment: 'Wow, good luck catching that bad meanie!', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(500); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts new file mode 100644 index 00000000000000..9e82a8ffaaec75 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -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 { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { + escapeHatch, + transformNewComment, + wrapError, + flattenCommentSavedObject, +} from '../../utils'; +import { RouteDeps } from '../../types'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/comments', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CommentRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + const createdBy = await caseService.getUser({ request, response }); + const createdDate = new Date().toISOString(); + + const newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: transformNewComment({ + createdDate, + ...query, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: myCase.id, + }, + ], + }); + + const updateCase = { + comment_ids: [...myCase.attributes.comment_ids, newComment.id], + }; + + await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + updatedAttributes: { + ...updateCase, + }, + }); + + return response.ok({ + body: CommentResponseRt.encode(flattenCommentSavedObject(newComment)), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts similarity index 60% rename from x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index 9ea42ba42406b1..cee705694f21d7 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -4,61 +4,76 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, mockCasesErrorTriggerData, + mockCaseComments, } from '../__fixtures__'; -import { initDeleteCaseApi } from '../delete_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initDeleteCasesApi } from './delete_cases'; describe('DELETE case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + routeHandler = await createRoute(initDeleteCasesApi, 'delete'); }); - it(`deletes the case. responds with 204`, async () => { + it(`deletes the case. responds with 200`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'mock-id-1', + query: { + ids: ['mock-id-1'], }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(204); + expect(response.status).toEqual(200); }); it(`returns an error when thrown from deleteCase service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'not-real', + query: { + ids: ['not-real'], }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); it(`returns an error when thrown from getAllCaseComments service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'bad-guy', + query: { + ids: ['bad-guy'], }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -66,15 +81,18 @@ describe('DELETE case', () => { }); it(`returns an error when thrown from deleteComment service`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'delete', - params: { - id: 'valid-id', + query: { + ids: ['valid-id'], }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts new file mode 100644 index 00000000000000..559a477a83a6cb --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; + +export function initDeleteCasesApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases', + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + await Promise.all( + request.query.ids.map(id => + caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: id, + }) + ) + ); + const comments = await Promise.all( + request.query.ids.map(id => + caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: id, + }) + ) + ); + + if (comments.some(c => c.saved_objects.length > 0)) { + await Promise.all( + comments.map(c => + Promise.all( + c.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ) + ) + ); + } + return response.ok({ body: 'true' }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts similarity index 84% rename from x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts rename to x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts index 96c411a746d49b..ec56c32f91745a 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, } from '../__fixtures__'; -import { initGetAllCasesApi } from '../get_all_cases'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initGetAllCasesApi } from './get_all_cases'; describe('GET all cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -25,7 +26,11 @@ describe('GET all cases', () => { method: 'get', }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts similarity index 52% rename from x-pack/plugins/case/server/routes/api/get_all_cases.ts rename to x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts index ba26a07dc23949..96b8e8c110c017 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts @@ -4,37 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { formatAllCases, sortToSnake, wrapError } from './utils'; -import { SavedObjectsFindOptionsSchema } from './schema'; -import { AllCases } from './types'; +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CasesResponseRt, SavedObjectFindOptionsRt, throwErrors } from '../../../../common/api'; +import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; export function initGetAllCasesApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases', + path: '/api/cases/_find', validate: { - query: schema.nullable(SavedObjectsFindOptionsSchema), + query: escapeHatch, }, }, async (context, request, response) => { try { - const args = request.query + const query = pipe( + SavedObjectFindOptionsRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const args = query ? { client: context.core.savedObjects.client, options: { - ...request.query, - sortField: sortToSnake(request.query.sortField ?? ''), + ...query, + sortField: sortToSnake(query.sortField ?? ''), }, } : { client: context.core.savedObjects.client, }; const cases = await caseService.getAllCases(args); - const body: AllCases = formatAllCases(cases); return response.ok({ - body, + body: CasesResponseRt.encode(transformCases(cases)), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts similarity index 74% rename from x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index 60becf1228a0c3..5912df2c40aa3f 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { CaseAttributes } from '../../../../common/api'; import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, mockCasesErrorTriggerData, + mockCaseComments, } from '../__fixtures__'; -import { initGetCaseApi } from '../get_case'; -import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; import { flattenCaseSavedObject } from '../utils'; -import { CaseAttributes } from '../types'; +import { initGetCaseApi } from './get_case'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; @@ -24,17 +26,21 @@ describe('GET case', () => { }); it(`returns the case with empty case comments when includeComments is false`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, - method: 'get', query: { includeComments: false, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -49,17 +55,21 @@ describe('GET case', () => { }); it(`returns an error when thrown from getCase`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'abcdefg', + case_id: 'abcdefg', }, - method: 'get', query: { includeComments: false, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -68,17 +78,22 @@ describe('GET case', () => { }); it(`returns the case with case comments when includeComments is true`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'mock-id-1', + case_id: 'mock-id-1', }, - method: 'get', query: { includeComments: true, }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); @@ -87,18 +102,20 @@ describe('GET case', () => { }); it(`returns an error when thrown from getAllCaseComments`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', + method: 'get', params: { - id: 'bad-guy', + case_id: 'bad-guy', }, - method: 'get', query: { includeComments: true, }, }); const theContext = createRouteContext( - createMockSavedObjectsRepository(mockCasesErrorTriggerData) + createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + }) ); const response = await routeHandler(theContext, request, kibanaResponseFactory); diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts similarity index 58% rename from x-pack/plugins/case/server/routes/api/get_case.ts rename to x-pack/plugins/case/server/routes/api/cases/get_case.ts index 2481197000beb5..1415513bca3460 100644 --- a/x-pack/plugins/case/server/routes/api/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -5,16 +5,18 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { flattenCaseSavedObject, wrapError } from './utils'; + +import { CaseResponseRt } from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { flattenCaseSavedObject, wrapError } from '../utils'; export function initGetCaseApi({ caseService, router }: RouteDeps) { router.get( { - path: '/api/cases/{id}', + path: '/api/cases/{case_id}', validate: { params: schema.object({ - id: schema.string(), + case_id: schema.string(), }), query: schema.object({ includeComments: schema.string({ defaultValue: 'true' }), @@ -22,26 +24,25 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, }, async (context, request, response) => { - let theCase; - const includeComments = JSON.parse(request.query.includeComments); try { - theCase = await caseService.getCase({ + const includeComments = JSON.parse(request.query.includeComments); + + const theCase = await caseService.getCase({ client: context.core.savedObjects.client, - caseId: request.params.id, + caseId: request.params.case_id, }); - } catch (error) { - return response.customError(wrapError(error)); - } - if (!includeComments) { - return response.ok({ body: flattenCaseSavedObject(theCase, []) }); - } - try { + + if (!includeComments) { + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, [])) }); + } + const theComments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, - caseId: request.params.id, + caseId: request.params.case_id, }); + return response.ok({ - body: { ...flattenCaseSavedObject(theCase, theComments.saved_objects) }, + body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, theComments.saved_objects)), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts similarity index 69% rename from x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts index 25d5cafb4bb06e..42fe9967ad0a04 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts @@ -4,35 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, + mockCaseComments, } from '../__fixtures__'; -import { initUpdateCaseApi } from '../update_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initPatchCaseApi } from './patch_case'; -describe('UPDATE case', () => { +describe('PATCH case', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { - routeHandler = await createRoute(initUpdateCaseApi, 'patch'); + routeHandler = await createRoute(initPatchCaseApi, 'patch'); }); - it(`Updates a case`, async () => { + it(`Patch a case`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { - case: { state: 'closed' }, + id: 'mock-id-1', + state: 'closed', version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); @@ -41,53 +45,61 @@ describe('UPDATE case', () => { }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { + id: 'mock-id-1', case: { state: 'closed' }, version: 'badv=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); it(`Fails with 406 if updated field is unchanged`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-1', - }, body: { + id: 'mock-id-1', case: { state: 'open' }, version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(406); }); it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ - path: '/api/cases/{id}', + path: '/api/cases', method: 'patch', - params: { - id: 'mock-id-does-not-exist', - }, body: { + id: 'mock-id-does-not-exist', state: 'closed', + version: 'WzAsMV0=', }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_case.ts b/x-pack/plugins/case/server/routes/api/cases/patch_case.ts new file mode 100644 index 00000000000000..eccede372c688c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/patch_case.ts @@ -0,0 +1,98 @@ +/* + * 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 Boom from 'boom'; +import { difference, get } from 'lodash'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CaseAttributes, + CasePatchRequestRt, + throwErrors, + CaseResponseRt, +} from '../../../../common/api'; +import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; +import { RouteDeps } from '../types'; + +export function initPatchCaseApi({ caseService, router }: RouteDeps) { + router.patch( + { + path: '/api/cases', + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CasePatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: query.id, + }); + + if (query.version !== myCase.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + const currentCase: CaseAttributes = myCase.attributes; + const updateCase: Partial<CaseAttributes> = Object.entries(query).reduce( + (acc, [key, value]) => { + const currentValue = get(currentCase, key); + if ( + currentValue != null && + Array.isArray(value) && + Array.isArray(currentValue) && + difference(value, currentValue).length !== 0 + ) { + return { + ...acc, + [key]: value, + }; + } else if (currentValue != null && value !== currentValue) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + {} + ); + if (Object.keys(updateCase).length > 0) { + const updatedBy = await caseService.getUser({ request, response }); + const { full_name, username } = updatedBy; + const updatedCase = await caseService.patchCase({ + client: context.core.savedObjects.client, + caseId: query.id, + updatedAttributes: { + ...updateCase, + updated_at: new Date().toISOString(), + updated_by: { full_name, username }, + }, + }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase.attributes }, + references: myCase.references, + }) + ), + }); + } + throw Boom.notAcceptable('All update fields are identical to current version.'); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts similarity index 82% rename from x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 32c7c5a015af02..0d14a659d2c42c 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + import { createMockSavedObjectsRepository, createRoute, createRouteContext, mockCases, } from '../__fixtures__'; -import { initPostCaseApi } from '../post_case'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; +import { initPostCaseApi } from './post_case'; describe('POST cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -31,11 +32,15 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.case_id).toEqual('mock-it'); + expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); }); it(`Returns an error if postNewCase throws`, async () => { @@ -50,7 +55,11 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -70,7 +79,11 @@ describe('POST cases', () => { }, }); - const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(500); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts new file mode 100644 index 00000000000000..9e854c3178e1e2 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -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 Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; + +import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { RouteDeps } from '../types'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CaseRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const createdBy = await caseService.getUser({ request, response }); + const createdDate = new Date().toISOString(); + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: transformNewCase({ + createdDate, + newCase: query, + ...createdBy, + }), + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts similarity index 89% rename from x-pack/plugins/case/server/routes/api/get_tags.ts rename to x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index 1d714db4c0c288..b1a2f10dd6f957 100644 --- a/x-pack/plugins/case/server/routes/api/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RouteDeps } from './index'; -import { wrapError } from './utils'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; export function initGetTagsApi({ caseService, router }: RouteDeps) { router.get( diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts deleted file mode 100644 index a5ae72b8b46ff0..00000000000000 --- a/x-pack/plugins/case/server/routes/api/delete_case.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { wrapError } from './utils'; - -export function initDeleteCaseApi({ caseService, router }: RouteDeps) { - router.delete( - { - path: '/api/cases/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - async (context, request, response) => { - let allCaseComments; - try { - await caseService.deleteCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - allCaseComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - if (allCaseComments.saved_objects.length > 0) { - await Promise.all( - allCaseComments.saved_objects.map(({ id }) => - caseService.deleteComment({ - client: context.core.savedObjects.client, - commentId: id, - }) - ) - ); - } - return response.noContent(); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts deleted file mode 100644 index 4a540dd9fd69ff..00000000000000 --- a/x-pack/plugins/case/server/routes/api/delete_comment.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { wrapError } from './utils'; - -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { - router.delete( - { - path: '/api/cases/comments/{comment_id}', - validate: { - params: schema.object({ - comment_id: schema.string(), - }), - }, - }, - async (context, request, response) => { - const client = context.core.savedObjects.client; - try { - await caseService.deleteComment({ - client, - commentId: request.params.comment_id, - }); - return response.noContent(); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts deleted file mode 100644 index b74227fa8d9838..00000000000000 --- a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '.'; -import { formatAllComments, wrapError } from './utils'; - -export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { - router.get( - { - path: '/api/cases/{id}/comments', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - async (context, request, response) => { - try { - const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - return response.ok({ body: formatAllComments(theComments) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 32dfd6a78d1c28..f4dca6a64c8d27 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -4,35 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'src/core/server'; -import { CaseServiceSetup } from '../../services'; -import { initDeleteCaseApi } from './delete_case'; -import { initDeleteCommentApi } from './delete_comment'; -import { initGetAllCaseCommentsApi } from './get_all_case_comments'; -import { initGetAllCasesApi } from './get_all_cases'; -import { initGetCaseApi } from './get_case'; -import { initGetCommentApi } from './get_comment'; -import { initGetTagsApi } from './get_tags'; -import { initPostCaseApi } from './post_case'; -import { initPostCommentApi } from './post_comment'; -import { initUpdateCaseApi } from './update_case'; -import { initUpdateCommentApi } from './update_comment'; +import { initDeleteCasesApi } from './cases/delete_cases'; +import { initGetAllCasesApi } from './cases/get_all_cases'; +import { initGetCaseApi } from './cases/get_case'; +import { initPatchCaseApi } from './cases/patch_case'; +import { initPostCaseApi } from './cases/post_case'; -export interface RouteDeps { - caseService: CaseServiceSetup; - router: IRouter; -} +import { initDeleteCommentApi } from './cases/comments/delete_comment'; +import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; +import { initFindCaseCommentsApi } from './cases/comments/find_comments'; +import { initGetAllCommentsApi } from './cases/comments/get_all_comment'; +import { initGetCommentApi } from './cases/comments/get_comment'; +import { initPatchCommentApi } from './cases/comments/patch_comment'; +import { initPostCommentApi } from './cases/comments/post_comment'; + +import { initGetTagsApi } from './cases/tags/get_tags'; + +import { RouteDeps } from './types'; export function initCaseApi(deps: RouteDeps) { - initDeleteCaseApi(deps); + initDeleteCasesApi(deps); initDeleteCommentApi(deps); - initGetAllCaseCommentsApi(deps); + initDeleteAllCommentsApi(deps); + initFindCaseCommentsApi(deps); initGetAllCasesApi(deps); initGetCaseApi(deps); initGetCommentApi(deps); + initGetAllCommentsApi(deps); initGetTagsApi(deps); initPostCaseApi(deps); initPostCommentApi(deps); - initUpdateCaseApi(deps); - initUpdateCommentApi(deps); + initPatchCaseApi(deps); + initPatchCommentApi(deps); } diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts deleted file mode 100644 index 948bf02d5b3c11..00000000000000 --- a/x-pack/plugins/case/server/routes/api/post_case.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { flattenCaseSavedObject, formatNewCase, wrapError } from './utils'; -import { NewCaseSchema } from './schema'; -import { RouteDeps } from '.'; - -export function initPostCaseApi({ caseService, router }: RouteDeps) { - router.post( - { - path: '/api/cases', - validate: { - body: NewCaseSchema, - }, - }, - async (context, request, response) => { - let createdBy; - try { - createdBy = await caseService.getUser({ request, response }); - } catch (error) { - return response.customError(wrapError(error)); - } - - try { - const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, - attributes: formatNewCase(request.body, { - ...createdBy, - }), - }); - return response.ok({ body: flattenCaseSavedObject(newCase, []) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts deleted file mode 100644 index f3f21becddfadc..00000000000000 --- a/x-pack/plugins/case/server/routes/api/post_comment.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { flattenCommentSavedObject, formatNewComment, wrapError } from './utils'; -import { NewCommentSchema } from './schema'; -import { RouteDeps } from '.'; -import { CASE_SAVED_OBJECT } from '../../constants'; - -export function initPostCommentApi({ caseService, router }: RouteDeps) { - router.post( - { - path: '/api/cases/{id}/comment', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: NewCommentSchema, - }, - }, - async (context, request, response) => { - let createdBy; - let newComment; - try { - await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - createdBy = await caseService.getUser({ request, response }); - } catch (error) { - return response.customError(wrapError(error)); - } - try { - newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, - attributes: formatNewComment({ - newComment: request.body, - ...createdBy, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: request.params.id, - }, - ], - }); - - return response.ok({ body: flattenCommentSavedObject(newComment) }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 5f1c207bf98297..1252fd19cda02d 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -3,74 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { IRouter } from 'src/core/server'; +import { CaseServiceSetup } from '../../services'; -import { TypeOf } from '@kbn/config-schema'; -import { - CommentSchema, - NewCaseSchema, - NewCommentSchema, - SavedObjectsFindOptionsSchema, - UpdatedCaseSchema, - UpdatedCommentSchema, - UserSchema, -} from './schema'; -import { SavedObjectAttributes } from '../../../../../../src/core/types'; - -export type NewCaseType = TypeOf<typeof NewCaseSchema>; -export type CommentAttributes = TypeOf<typeof CommentSchema> & SavedObjectAttributes; -export type NewCommentType = TypeOf<typeof NewCommentSchema>; -export type SavedObjectsFindOptionsType = TypeOf<typeof SavedObjectsFindOptionsSchema>; -export type UpdatedCaseTyped = TypeOf<typeof UpdatedCaseSchema>; -export type UpdatedCommentType = TypeOf<typeof UpdatedCommentSchema>; -export type UserType = TypeOf<typeof UserSchema>; - -export interface CaseAttributes extends NewCaseType, SavedObjectAttributes { - created_at: string; - created_by: UserType; - updated_at: string; -} - -export type FlattenedCaseSavedObject = CaseAttributes & { - case_id: string; - version: string; - comments: FlattenedCommentSavedObject[]; -}; - -export type FlattenedCasesSavedObject = Array< - CaseAttributes & { - case_id: string; - version: string; - // TO DO it is partial because we need to add it the commentCount - commentCount?: number; - } ->; - -export interface AllCases { - cases: FlattenedCasesSavedObject; - page: number; - per_page: number; - total: number; -} - -export type FlattenedCommentSavedObject = CommentAttributes & { - comment_id: string; - version: string; - // TO DO We might want to add the case_id where this comment is related too -}; - -export interface AllComments { - comments: FlattenedCommentSavedObject[]; - page: number; - per_page: number; - total: number; -} - -export interface UpdatedCaseType { - description?: UpdatedCaseTyped['description']; - state?: UpdatedCaseTyped['state']; - tags?: UpdatedCaseTyped['tags']; - title?: UpdatedCaseTyped['title']; - updated_at: string; +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; } export enum SortFieldCase { @@ -78,7 +16,3 @@ export enum SortFieldCase { state = 'state', updatedAt = 'updated_at', } - -export type Writable<T> = { - -readonly [K in keyof T]: T[K]; -}; diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts deleted file mode 100644 index 1c1a56dfe9b3a7..00000000000000 --- a/x-pack/plugins/case/server/routes/api/update_case.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { SavedObject } from 'kibana/server'; -import Boom from 'boom'; -import { difference } from 'lodash'; -import { wrapError } from './utils'; -import { RouteDeps } from '.'; -import { UpdateCaseArguments } from './schema'; -import { CaseAttributes, UpdatedCaseTyped, Writable } from './types'; - -interface UpdateCase extends Writable<UpdatedCaseTyped> { - [key: string]: any; -} - -export function initUpdateCaseApi({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: UpdateCaseArguments, - }, - }, - async (context, request, response) => { - let theCase: SavedObject<CaseAttributes>; - try { - theCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - - if (request.body.version !== theCase.version) { - return response.customError( - wrapError( - Boom.conflict( - 'This case has been updated. Please refresh before saving additional updates.' - ) - ) - ); - } - const currentCase = theCase.attributes; - const updateCase: Partial<UpdateCase> = Object.entries(request.body.case).reduce( - (acc, [key, value]) => { - const currentValue = currentCase[key]; - if ( - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { - return { - ...acc, - [key]: value, - }; - } else if (value !== currentCase[key]) { - return { - ...acc, - [key]: value, - }; - } - return acc; - }, - {} - ); - if (Object.keys(updateCase).length > 0) { - try { - const updatedCase = await caseService.updateCase({ - client: context.core.savedObjects.client, - caseId: request.params.id, - updatedAttributes: { - ...updateCase, - updated_at: new Date().toISOString(), - }, - }); - return response.ok({ body: { ...updatedCase.attributes, version: updatedCase.version } }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - return response.customError( - wrapError(Boom.notAcceptable('All update fields are identical to current version.')) - ); - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts deleted file mode 100644 index 9f99253f766296..00000000000000 --- a/x-pack/plugins/case/server/routes/api/update_comment.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { SavedObject } from 'kibana/server'; -import Boom from 'boom'; -import { wrapError } from './utils'; -import { UpdateCommentArguments } from './schema'; -import { RouteDeps } from '.'; -import { CommentAttributes } from './types'; - -export function initUpdateCommentApi({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases/comment/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - body: UpdateCommentArguments, - }, - }, - async (context, request, response) => { - let theComment: SavedObject<CommentAttributes>; - try { - theComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: request.params.id, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - if (request.body.version !== theComment.version) { - return response.customError( - wrapError( - Boom.conflict( - 'This comment has been updated. Please refresh before saving additional updates.' - ) - ) - ); - } - if (request.body.comment === theComment.attributes.comment) { - return response.customError( - wrapError(Boom.notAcceptable('Comment is identical to current version.')) - ); - } - try { - const updatedComment = await caseService.updateComment({ - client: context.core.savedObjects.client, - commentId: request.params.id, - updatedAttributes: { - comment: request.body.comment, - updated_at: new Date().toISOString(), - }, - }); - return response.ok({ - body: { ...updatedComment.attributes, version: updatedComment.version }, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 32de41e1c01c5a..920c53f4044566 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; import { boomify, isBoom } from 'boom'; import { CustomHttpResponseOptions, @@ -12,42 +13,53 @@ import { SavedObjectsFindResponse, } from 'kibana/server'; import { - AllComments, + CaseRequest, + CaseResponse, + CasesResponse, CaseAttributes, + CommentResponse, + CommentsResponse, CommentAttributes, - FlattenedCaseSavedObject, - FlattenedCommentSavedObject, - AllCases, - NewCaseType, - NewCommentType, - SortFieldCase, - UserType, -} from './types'; +} from '../../../common/api'; -export const formatNewCase = ( - newCase: NewCaseType, - { full_name, username }: { full_name?: string; username: string } -): CaseAttributes => ({ - created_at: new Date().toISOString(), +import { SortFieldCase } from './types'; + +export const transformNewCase = ({ + createdDate, + newCase, + full_name, + username, +}: { + createdDate: string; + newCase: CaseRequest; + full_name?: string | null; + username: string | null; +}): CaseAttributes => ({ + comment_ids: [], + created_at: createdDate, created_by: { full_name, username }, - updated_at: new Date().toISOString(), + updated_at: null, + updated_by: null, ...newCase, }); interface NewCommentArgs { - newComment: NewCommentType; - full_name?: UserType['full_name']; - username: UserType['username']; + comment: string; + createdDate: string; + full_name?: string | null; + username: string | null; } -export const formatNewComment = ({ - newComment, +export const transformNewComment = ({ + comment, + createdDate, full_name, username, }: NewCommentArgs): CommentAttributes => ({ - ...newComment, - created_at: new Date().toISOString(), + comment, + created_at: createdDate, created_by: { full_name, username }, - updated_at: new Date().toISOString(), + updated_at: null, + updated_by: null, }); export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> { @@ -59,7 +71,7 @@ export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> }; } -export const formatAllCases = (cases: SavedObjectsFindResponse<CaseAttributes>): AllCases => ({ +export const transformCases = (cases: SavedObjectsFindResponse<CaseAttributes>): CasesResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, @@ -68,27 +80,24 @@ export const formatAllCases = (cases: SavedObjectsFindResponse<CaseAttributes>): export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse<CaseAttributes>['saved_objects'] -): FlattenedCaseSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCaseSavedObject[], savedObject: SavedObject<CaseAttributes>) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; - }, - [] - ); +): CaseResponse[] => + savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject<CaseAttributes>) => { + return [...acc, flattenCaseSavedObject(savedObject, [])]; + }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject<CaseAttributes>, - comments: Array<SavedObject<CommentAttributes>> -): FlattenedCaseSavedObject => ({ - case_id: savedObject.id, - version: savedObject.version ? savedObject.version : '0', + comments: Array<SavedObject<CommentAttributes>> = [] +): CaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), ...savedObject.attributes, }); -export const formatAllComments = ( +export const transformComments = ( comments: SavedObjectsFindResponse<CommentAttributes> -): AllComments => ({ +): CommentsResponse => ({ page: comments.page, per_page: comments.per_page, total: comments.total, @@ -97,19 +106,16 @@ export const formatAllComments = ( export const flattenCommentSavedObjects = ( savedObjects: SavedObjectsFindResponse<CommentAttributes>['saved_objects'] -): FlattenedCommentSavedObject[] => - savedObjects.reduce( - (acc: FlattenedCommentSavedObject[], savedObject: SavedObject<CommentAttributes>) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, - [] - ); +): CommentResponse[] => + savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject<CommentAttributes>) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, []); export const flattenCommentSavedObject = ( savedObject: SavedObject<CommentAttributes> -): FlattenedCommentSavedObject => ({ - comment_id: savedObject.id, - version: savedObject.version ? savedObject.version : '0', +): CommentResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', ...savedObject.attributes, }); @@ -127,3 +133,5 @@ export const sortToSnake = (sortField: string): SortFieldCase => { return SortFieldCase.createdAt; } }; + +export const escapeHatch = schema.object({}, { allowUnknowns: true }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts new file mode 100644 index 00000000000000..faed0a3100a426 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_SAVED_OBJECT = 'cases'; + +export const caseSavedObjectType: SavedObjectsType = { + name: CASE_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + comment_ids: { + type: 'keyword', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts new file mode 100644 index 00000000000000..51c31421fec2fd --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -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 { SavedObjectsType } from 'src/core/server'; + +export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; + +export const caseCommentSavedObjectType: SavedObjectsType = { + name: CASE_COMMENT_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts new file mode 100644 index 00000000000000..1e29b9dd98ead4 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index e6416e268e30bf..61b696d45d0305 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -14,15 +14,10 @@ import { SavedObjectsUpdateResponse, SavedObjectReference, } from 'kibana/server'; -import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; -import { - CaseAttributes, - CommentAttributes, - SavedObjectsFindOptionsType, - UpdatedCaseType, - UpdatedCommentType, -} from '../routes/api/types'; + import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; +import { CaseAttributes, CommentAttributes, SavedObjectFindOptions } from '../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readTags } from './tags/read_tags'; interface ClientArgs { @@ -33,8 +28,12 @@ interface GetCaseArgs extends ClientArgs { caseId: string; } +interface GetCommentsArgs extends GetCaseArgs { + options?: SavedObjectFindOptions; +} + interface GetCasesArgs extends ClientArgs { - options?: SavedObjectsFindOptionsType; + options?: SavedObjectFindOptions; } interface GetCommentArgs extends ClientArgs { commentId: string; @@ -47,13 +46,13 @@ interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; } -interface UpdateCaseArgs extends ClientArgs { +interface PatchCaseArgs extends ClientArgs { caseId: string; - updatedAttributes: UpdatedCaseType; + updatedAttributes: Partial<CaseAttributes>; } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: UpdatedCommentType; + updatedAttributes: Partial<CommentAttributes>; } interface GetUserArgs { @@ -68,15 +67,15 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; getAllCases(args: GetCasesArgs): Promise<SavedObjectsFindResponse<CaseAttributes>>; - getAllCaseComments(args: GetCaseArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>; + getAllCaseComments(args: GetCommentsArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>; getCase(args: GetCaseArgs): Promise<SavedObject<CaseAttributes>>; getComment(args: GetCommentArgs): Promise<SavedObject<CommentAttributes>>; getTags(args: ClientArgs): Promise<string[]>; getUser(args: GetUserArgs): Promise<AuthenticatedUser>; postNewCase(args: PostCaseArgs): Promise<SavedObject<CaseAttributes>>; postNewComment(args: PostCommentArgs): Promise<SavedObject<CommentAttributes>>; - updateCase(args: UpdateCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>; - updateComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>; + patchCase(args: PatchCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>; + patchComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>; } export class CaseService { @@ -127,10 +126,11 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ + ...options, type: CASE_COMMENT_SAVED_OBJECT, hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, }); @@ -175,7 +175,7 @@ export class CaseService { throw error; } }, - updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + patchCase: async ({ client, caseId, updatedAttributes }: PatchCaseArgs) => { try { this.log.debug(`Attempting to UPDATE case ${caseId}`); return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); @@ -184,7 +184,7 @@ export class CaseService { throw error; } }, - updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + patchComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { try { this.log.debug(`Attempting to UPDATE comment ${commentId}`); return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { diff --git a/x-pack/plugins/case/server/services/tags/read_tags.ts b/x-pack/plugins/case/server/services/tags/read_tags.ts index da5905fe4ea358..ddb79507b5fefb 100644 --- a/x-pack/plugins/case/server/services/tags/read_tags.ts +++ b/x-pack/plugins/case/server/services/tags/read_tags.ts @@ -5,8 +5,9 @@ */ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; -import { CASE_SAVED_OBJECT } from '../../constants'; -import { CaseAttributes } from '../..'; + +import { CaseAttributes } from '../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../saved_object_types'; const DEFAULT_PER_PAGE: number = 1000; @@ -23,7 +24,7 @@ export const convertTagsToSet = (tagObjects: Array<SavedObject<CaseAttributes>>) return new Set(convertToTags(tagObjects)); }; -// Note: This is doing an in-memory aggregation of the tags by calling each of the alerting +// Note: This is doing an in-memory aggregation of the tags by calling each of the case // records in batches of this const setting and uses the fields to try to get the least // amount of data per record back. If saved objects at some point supports aggregations // then this should be replaced with a an aggregation call. diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts new file mode 100644 index 00000000000000..0d5e353b0e83b0 --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EnhancedSearchParams, IEnhancedEsSearchRequest } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts new file mode 100644 index 00000000000000..3fe4fd029b940e --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EnhancedSearchParams, IEnhancedEsSearchRequest } from './types'; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts new file mode 100644 index 00000000000000..59ce9f0b36f208 --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { SearchParams } from 'elasticsearch'; +import { IEsSearchRequest } from '../../../../../src/plugins/data/common'; + +export interface EnhancedSearchParams extends SearchParams { + ignoreThrottled: boolean; +} + +export interface IEnhancedEsSearchRequest extends IEsSearchRequest { + /** + * Used to determine whether to use the _rollups_search or a regular search endpoint. + */ + isRollup?: boolean; +} diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 4dbfe958eff68f..b2d5f42d9e4687 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -1,5 +1,5 @@ { - "id": "data_enhanced", + "id": "dataEnhanced", "version": "8.0.0", "kibanaVersion": "kibana", "configPath": [ @@ -8,6 +8,6 @@ "requiredPlugins": [ "data" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 4fe27d400f45fd..6316d87c505193 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -5,10 +5,18 @@ */ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + ES_SEARCH_STRATEGY, +} from '../../../../src/plugins/data/public'; import { setAutocompleteService } from './services'; import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; -import { ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider } from './search'; +import { + ASYNC_SEARCH_STRATEGY, + asyncSearchStrategyProvider, + enhancedEsSearchStrategyProvider, +} from './search'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -29,6 +37,10 @@ export class DataEnhancedPlugin implements Plugin { setupKqlQuerySuggestionProvider(core) ); data.search.registerSearchStrategyProvider(ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider); + data.search.registerSearchStrategyProvider( + ES_SEARCH_STRATEGY, + enhancedEsSearchStrategyProvider + ); } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { diff --git a/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts new file mode 100644 index 00000000000000..25c6a789cca930 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/es_search_strategy.ts @@ -0,0 +1,41 @@ +/* + * 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 { Observable } from 'rxjs'; +import { ES_SEARCH_STRATEGY, IEsSearchResponse } from '../../../../../src/plugins/data/common'; +import { + TSearchStrategyProvider, + ISearchContext, + ISearch, + SYNC_SEARCH_STRATEGY, + getEsPreference, +} from '../../../../../src/plugins/data/public'; +import { IEnhancedEsSearchRequest, EnhancedSearchParams } from '../../common'; + +export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider<typeof ES_SEARCH_STRATEGY> = ( + context: ISearchContext +) => { + const syncStrategyProvider = context.getSearchStrategy(SYNC_SEARCH_STRATEGY); + const { search: syncSearch } = syncStrategyProvider(context); + + const search: ISearch<typeof ES_SEARCH_STRATEGY> = ( + request: IEnhancedEsSearchRequest, + options + ) => { + const params: EnhancedSearchParams = { + ignoreThrottled: !context.core.uiSettings.get<boolean>('search:includeFrozen'), + preference: getEsPreference(context.core.uiSettings), + ...request.params, + }; + request.params = params; + + return syncSearch({ ...request, serverStrategy: ES_SEARCH_STRATEGY }, options) as Observable< + IEsSearchResponse + >; + }; + + return { search }; +}; diff --git a/x-pack/plugins/data_enhanced/public/search/index.ts b/x-pack/plugins/data_enhanced/public/search/index.ts index a7729aeea56478..e39c1b6a1dd618 100644 --- a/x-pack/plugins/data_enhanced/public/search/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/index.ts @@ -5,4 +5,5 @@ */ export { ASYNC_SEARCH_STRATEGY, asyncSearchStrategyProvider } from './async_search_strategy'; +export { enhancedEsSearchStrategyProvider } from './es_search_strategy'; export { IAsyncSearchRequest, IAsyncSearchOptions } from './types'; diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts new file mode 100644 index 00000000000000..fbe1ecc10d6328 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/server'; +import { EnhancedDataServerPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new EnhancedDataServerPlugin(initializerContext); +} + +export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts new file mode 100644 index 00000000000000..a27a73431574b5 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -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 { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, +} from '../../../../src/core/server'; +import { ES_SEARCH_STRATEGY } from '../../../../src/plugins/data/common'; +import { PluginSetup as DataPluginSetup } from '../../../../src/plugins/data/server'; +import { enhancedEsSearchStrategyProvider } from './search'; + +interface SetupDependencies { + data: DataPluginSetup; +} + +export class EnhancedDataServerPlugin implements Plugin<void, void, SetupDependencies> { + constructor(private initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, deps: SetupDependencies) { + deps.data.search.registerSearchStrategyProvider( + this.initializerContext.opaqueId, + ES_SEARCH_STRATEGY, + enhancedEsSearchStrategyProvider + ); + } + + public start(core: CoreStart) {} + + public stop() {} +} + +export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts new file mode 100644 index 00000000000000..6e12ffb6404c62 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'rxjs/operators'; +import { mapKeys, snakeCase } from 'lodash'; +import { SearchResponse } from 'elasticsearch'; +import { APICaller } from '../../../../../src/core/server'; +import { ES_SEARCH_STRATEGY } from '../../../../../src/plugins/data/common'; +import { + ISearchContext, + TSearchStrategyProvider, + ISearch, + ISearchOptions, + getDefaultSearchParams, +} from '../../../../../src/plugins/data/server'; +import { IEnhancedEsSearchRequest } from '../../common'; + +export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider<typeof ES_SEARCH_STRATEGY> = ( + context: ISearchContext, + caller: APICaller +) => { + const search: ISearch<typeof ES_SEARCH_STRATEGY> = async ( + request: IEnhancedEsSearchRequest, + options + ) => { + const config = await context.config$.pipe(first()).toPromise(); + const defaultParams = getDefaultSearchParams(config); + const params = { ...defaultParams, ...request.params }; + + const rawResponse = (await (request.isRollup + ? rollupSearch(caller, { ...request, params }, options) + : caller('search', params, options))) as SearchResponse<any>; + + const { total, failed, successful } = rawResponse._shards; + const loaded = failed + successful; + return { total, loaded, rawResponse }; + }; + + return { search }; +}; + +function rollupSearch( + caller: APICaller, + request: IEnhancedEsSearchRequest, + options?: ISearchOptions +) { + const method = 'POST'; + const path = `${request.params.index}/_rollup_search`; + const { body, ...params } = request.params; + const query = toSnakeCase(params); + return caller('transport.request', { method, path, body, query }, options); +} + +function toSnakeCase(obj: Record<string, any>) { + return mapKeys(obj, (value, key) => snakeCase(key)); +} diff --git a/x-pack/plugins/data_enhanced/server/search/index.ts b/x-pack/plugins/data_enhanced/server/search/index.ts new file mode 100644 index 00000000000000..f914326f30d32a --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { enhancedEsSearchStrategyProvider } from './es_search_strategy'; diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx index 1db57eb3d0b289..4834cc80813742 100644 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx @@ -25,13 +25,13 @@ export interface OpenFlyoutAddDrilldownParams { export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_ADD_DRILLDOWN> { public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 5; + public order = 100; constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} public getDisplayName() { return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create Drilldown', + defaultMessage: 'Create drilldown', }); } @@ -40,7 +40,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY } public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return true; + return embeddable.getInput().viewMode === 'edit'; } public async execute(context: FlyoutCreateDrilldownActionContext) { diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 00000000000000..f109da94fcaca7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { EuiNotificationBadge } from '@elastic/eui'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; +import { + toMountPoint, + reactToUiComponent, +} from '../../../../../../src/plugins/kibana_react/public'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { FormCreateDrilldown } from '../../components/form_create_drilldown'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownActionContext { + embeddable: IEmbeddable; +} + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise<CoreStart['overlays']>; +} + +const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { + defaultMessage: 'Manage drilldowns', +}); + +// mocked data +const drilldrownCount = 2; + +export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_EDIT_DRILLDOWN> { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 100; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return displayName; + } + + public getIconType() { + return 'list'; + } + + private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { + return ( + <> + {displayName}{' '} + <EuiNotificationBadge color="subdued" style={{ float: 'right' }}> + {drilldrownCount} + </EuiNotificationBadge> + </> + ); + }; + + MenuItem = reactToUiComponent(this.ReactComp); + + public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { + return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; + } + + public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { + const overlays = await this.params.overlays(); + overlays.openFlyout(toMountPoint(<FormCreateDrilldown />)); + } +} diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/drilldowns/public/actions/index.ts index ce235043b4ef6e..4a0a34f08428ab 100644 --- a/x-pack/plugins/drilldowns/public/actions/index.ts +++ b/x-pack/plugins/drilldowns/public/actions/index.ts @@ -5,3 +5,4 @@ */ export * from './flyout_create_drilldown'; +export * from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index 1761e17d55986a..b89172541b91e3 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -7,7 +7,12 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { DrilldownService } from './service'; -import { FlyoutCreateDrilldownActionContext, OPEN_FLYOUT_ADD_DRILLDOWN } from './actions'; +import { + FlyoutCreateDrilldownActionContext, + FlyoutEditDrilldownActionContext, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; export interface DrilldownsSetupDependencies { uiActions: UiActionsSetup; @@ -25,6 +30,7 @@ export interface DrilldownsStartContract {} declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; } } diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts index 715b0ce8e60e1e..7209045191e94a 100644 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts @@ -6,17 +6,20 @@ import { CoreSetup } from 'src/core/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction } from '../actions'; +import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; import { DrilldownsSetupDependencies } from '../plugin'; export class DrilldownService { bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ - overlays: async () => (await core.getStartServices())[0].overlays, - }); + const overlays = async () => (await core.getStartServices())[0].overlays; + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); uiActions.registerAction(actionFlyoutCreateDrilldown); uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); + uiActions.registerAction(actionFlyoutEditDrilldown); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); } /** diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts index e05d8d687d05a3..8f74c461a2a9b9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts @@ -37,13 +37,13 @@ describe('config schema', () => { expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }) ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` ); expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true }) ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` ); }); }); diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index c88ce9c1413b33..b1e5ab015aa5fd 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -31,9 +31,9 @@ export enum Direction { export class EndpointAppConstants { static BASE_API_URL = '/api/endpoint'; - static ALERT_INDEX_NAME = 'my-index'; static ENDPOINT_INDEX_NAME = 'endpoint-agent*'; - static EVENT_INDEX_NAME = 'endpoint-events-*'; + static ALERT_INDEX_NAME = 'events-endpoint-1'; + static EVENT_INDEX_NAME = 'events-endpoint-*'; static DEFAULT_TOTAL_HITS = 10000; /** * Legacy events are stored in indices with endgame-* prefix diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts index 86dd4c053e8fa0..4db8ee0bfbcef8 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts @@ -7,7 +7,7 @@ import { ResolverEvent, LegacyEndpointEvent } from '../../../../common/types'; function isLegacyData(data: ResolverEvent): data is LegacyEndpointEvent { - return data.agent.type === 'endgame'; + return data.agent?.type === 'endgame'; } export function extractEventID(event: ResolverEvent) { diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index f12bbd6cf77235..bb40d65d311e8a 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -9,7 +9,7 @@ "spaces", "home", "data", - "data_enhanced", + "dataEnhanced", "metrics", "alerting" ], diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 9c1a1bb5962e46..566cc7ec09336a 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -5,7 +5,7 @@ */ // import { darken, transparentize } from 'polished'; -import React, { useState, useCallback, useMemo } from 'react'; +import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; import { @@ -41,155 +41,157 @@ interface LogEntryRowProps { wrap: boolean; } -export const LogEntryRow = ({ - boundingBoxRef, - columnConfigurations, - columnWidths, - highlights, - isActiveHighlight, - isHighlighted, - logEntry, - openFlyoutWithItem, - scale, - wrap, -}: LogEntryRowProps) => { - const [isHovered, setIsHovered] = useState(false); - - const setItemIsHovered = useCallback(() => { - setIsHovered(true); - }, []); - - const setItemIsNotHovered = useCallback(() => { - setIsHovered(false); - }, []); - - const openFlyout = useCallback(() => openFlyoutWithItem(logEntry.gid), [ +export const LogEntryRow = memo( + ({ + boundingBoxRef, + columnConfigurations, + columnWidths, + highlights, + isActiveHighlight, + isHighlighted, + logEntry, openFlyoutWithItem, - logEntry.gid, - ]); - - const logEntryColumnsById = useMemo( - () => - logEntry.columns.reduce<{ - [columnId: string]: LogEntry['columns'][0]; - }>( - (columnsById, column) => ({ - ...columnsById, - [column.columnId]: column, - }), - {} - ), - [logEntry.columns] - ); - - const highlightsByColumnId = useMemo( - () => - highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; - }>( - (columnsById, highlight) => - highlight.columns.reduce( - (innerColumnsById, column) => ({ - ...innerColumnsById, - [column.columnId]: [...(innerColumnsById[column.columnId] || []), column], - }), - columnsById - ), - {} - ), - [highlights] - ); - - return ( - <LogEntryRowWrapper - data-test-subj="streamEntry logTextStreamEntry" - ref={ - /* Workaround for missing RefObject support in styled-components */ - boundingBoxRef as any - } - onMouseEnter={setItemIsHovered} - onMouseLeave={setItemIsNotHovered} - scale={scale} - > - {columnConfigurations.map(columnConfiguration => { - if (isTimestampLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; - const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; - - return ( - <LogEntryColumn - data-test-subj="logColumn timestampLogColumn" - key={columnConfiguration.timestampColumn.id} - {...columnWidth} - > - {isTimestampColumn(column) ? ( - <LogEntryTimestampColumn - isHighlighted={isHighlighted} - isHovered={isHovered} - time={column.timestamp} - /> - ) : null} - </LogEntryColumn> - ); - } else if (isMessageLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; - const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; - - return ( - <LogEntryColumn - data-test-subj="logColumn messageLogColumn" - key={columnConfiguration.messageColumn.id} - {...columnWidth} - > - {column ? ( - <LogEntryMessageColumn - columnValue={column} - highlights={highlightsByColumnId[column.columnId] || []} - isHighlighted={isHighlighted} - isActiveHighlight={isActiveHighlight} - isHovered={isHovered} - isWrapped={wrap} - /> - ) : null} - </LogEntryColumn> - ); - } else if (isFieldLogColumnConfiguration(columnConfiguration)) { - const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; - const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; - - return ( - <LogEntryColumn - data-test-subj={`logColumn fieldLogColumn fieldLogColumn:${columnConfiguration.fieldColumn.field}`} - key={columnConfiguration.fieldColumn.id} - {...columnWidth} - > - {column ? ( - <LogEntryFieldColumn - columnValue={column} - highlights={highlightsByColumnId[column.columnId] || []} - isActiveHighlight={isActiveHighlight} - isHighlighted={isHighlighted} - isHovered={isHovered} - isWrapped={wrap} - /> - ) : null} - </LogEntryColumn> - ); + scale, + wrap, + }: LogEntryRowProps) => { + const [isHovered, setIsHovered] = useState(false); + + const setItemIsHovered = useCallback(() => { + setIsHovered(true); + }, []); + + const setItemIsNotHovered = useCallback(() => { + setIsHovered(false); + }, []); + + const openFlyout = useCallback(() => openFlyoutWithItem(logEntry.gid), [ + openFlyoutWithItem, + logEntry.gid, + ]); + + const logEntryColumnsById = useMemo( + () => + logEntry.columns.reduce<{ + [columnId: string]: LogEntry['columns'][0]; + }>( + (columnsById, column) => ({ + ...columnsById, + [column.columnId]: column, + }), + {} + ), + [logEntry.columns] + ); + + const highlightsByColumnId = useMemo( + () => + highlights.reduce<{ + [columnId: string]: LogEntryHighlightColumn[]; + }>( + (columnsById, highlight) => + highlight.columns.reduce( + (innerColumnsById, column) => ({ + ...innerColumnsById, + [column.columnId]: [...(innerColumnsById[column.columnId] || []), column], + }), + columnsById + ), + {} + ), + [highlights] + ); + + return ( + <LogEntryRowWrapper + data-test-subj="streamEntry logTextStreamEntry" + ref={ + /* Workaround for missing RefObject support in styled-components */ + boundingBoxRef as any } - })} - <LogEntryColumn - key="logColumn iconLogColumn iconLogColumn:details" - {...columnWidths[iconColumnId]} + onMouseEnter={setItemIsHovered} + onMouseLeave={setItemIsNotHovered} + scale={scale} > - <LogEntryDetailsIconColumn - isHighlighted={isHighlighted} - isHovered={isHovered} - openFlyout={openFlyout} - /> - </LogEntryColumn> - </LogEntryRowWrapper> - ); -}; + {columnConfigurations.map(columnConfiguration => { + if (isTimestampLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.timestampColumn.id]; + const columnWidth = columnWidths[columnConfiguration.timestampColumn.id]; + + return ( + <LogEntryColumn + data-test-subj="logColumn timestampLogColumn" + key={columnConfiguration.timestampColumn.id} + {...columnWidth} + > + {isTimestampColumn(column) ? ( + <LogEntryTimestampColumn + isHighlighted={isHighlighted} + isHovered={isHovered} + time={column.timestamp} + /> + ) : null} + </LogEntryColumn> + ); + } else if (isMessageLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.messageColumn.id]; + const columnWidth = columnWidths[columnConfiguration.messageColumn.id]; + + return ( + <LogEntryColumn + data-test-subj="logColumn messageLogColumn" + key={columnConfiguration.messageColumn.id} + {...columnWidth} + > + {column ? ( + <LogEntryMessageColumn + columnValue={column} + highlights={highlightsByColumnId[column.columnId] || []} + isHighlighted={isHighlighted} + isActiveHighlight={isActiveHighlight} + isHovered={isHovered} + isWrapped={wrap} + /> + ) : null} + </LogEntryColumn> + ); + } else if (isFieldLogColumnConfiguration(columnConfiguration)) { + const column = logEntryColumnsById[columnConfiguration.fieldColumn.id]; + const columnWidth = columnWidths[columnConfiguration.fieldColumn.id]; + + return ( + <LogEntryColumn + data-test-subj={`logColumn fieldLogColumn fieldLogColumn:${columnConfiguration.fieldColumn.field}`} + key={columnConfiguration.fieldColumn.id} + {...columnWidth} + > + {column ? ( + <LogEntryFieldColumn + columnValue={column} + highlights={highlightsByColumnId[column.columnId] || []} + isActiveHighlight={isActiveHighlight} + isHighlighted={isHighlighted} + isHovered={isHovered} + isWrapped={wrap} + /> + ) : null} + </LogEntryColumn> + ); + } + })} + <LogEntryColumn + key="logColumn iconLogColumn iconLogColumn:details" + {...columnWidths[iconColumnId]} + > + <LogEntryDetailsIconColumn + isHighlighted={isHighlighted} + isHovered={isHovered} + openFlyout={openFlyout} + /> + </LogEntryColumn> + </LogEntryRowWrapper> + ); + } +); interface LogEntryRowWrapperProps { scale: TextScale; diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index aaf3fc357352ee..30929ba98d33bf 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -132,7 +132,7 @@ describe('setupAuthentication()', () => { expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith(); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); expect(authenticate).not.toHaveBeenCalled(); @@ -155,7 +155,7 @@ describe('setupAuthentication()', () => { state: mockUser, requestHeaders: mockAuthHeaders, }); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); expect(authenticate).toHaveBeenCalledTimes(1); @@ -184,7 +184,7 @@ describe('setupAuthentication()', () => { requestHeaders: mockAuthHeaders, responseHeaders: mockAuthResponseHeaders, }); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); expect(authenticate).toHaveBeenCalledTimes(1); @@ -197,9 +197,9 @@ describe('setupAuthentication()', () => { await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - expect(mockResponse.redirected).toHaveBeenCalledTimes(1); - expect(mockResponse.redirected).toHaveBeenCalledWith({ - headers: { location: '/some/url' }, + expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({ + location: '/some/url', }); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); @@ -216,7 +216,7 @@ describe('setupAuthentication()', () => { expect(error).toBeUndefined(); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); expect(loggingServiceMock.collect(mockSetupAuthenticationParams.loggers).error) .toMatchInlineSnapshot(` Array [ @@ -239,7 +239,7 @@ describe('setupAuthentication()', () => { expect(response.body).toBe(esError); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); }); it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { @@ -264,22 +264,19 @@ describe('setupAuthentication()', () => { expect(options!.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' }); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); }); - it('returns `unauthorized` when authentication can not be handled', async () => { + it('returns `notHandled` when authentication can not be handled', async () => { const mockResponse = httpServerMock.createLifecycleResponseFactory(); authenticate.mockResolvedValue(AuthenticationResult.notHandled()); await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - expect(mockResponse.unauthorized).toHaveBeenCalledTimes(1); - const [[response]] = mockResponse.unauthorized.mock.calls; - - expect(response!.body).toBeUndefined(); + expect(mockAuthToolkit.notHandled).toHaveBeenCalledTimes(1); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockResponse.redirected).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 189babbc6bfe67..1eed53efc64415 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -127,10 +127,8 @@ export async function setupAuthentication({ // authentication (username and password) or arbitrary external page managed by 3rd party // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who // decides what location user should be redirected to. - return response.redirected({ - headers: { - location: authenticationResult.redirectURL!, - }, + return t.redirected({ + location: authenticationResult.redirectURL!, }); } @@ -153,9 +151,7 @@ export async function setupAuthentication({ } authLogger.debug('Could not handle authentication attempt'); - return response.unauthorized({ - headers: authenticationResult.authResponseHeaders, - }); + return t.notHandled(); }); authLogger.debug('Successfully registered core authentication handler.'); diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 00a50dd5b8821b..4cbc76ecb6be43 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -112,8 +112,7 @@ export function setupAuthorization({ authz ); - // if we're an anonymous route, we disable all ui capabilities - if (request.route.options.authRequired === false) { + if (!request.auth.isAuthenticated) { return disableUICapabilities.all(capabilities); } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 9f7f2736766ed0..03285184d65722 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -103,13 +103,13 @@ describe('config schema', () => { expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }) ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` ); expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true }) ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` ); }); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index 694d0fca97a2c2..cd3b8716715510 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -85,17 +85,17 @@ describe('Basic authentication routes', () => { expect(() => bodyValidator.validate({ username: '', password: '' }) ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value is [] but it must have a minimum length of [1]."` + `"[username]: value has length [0] but it must have a minimum length of [1]."` ); expect(() => bodyValidator.validate({ username: 'user', password: '' }) ).toThrowErrorMatchingInlineSnapshot( - `"[password]: value is [] but it must have a minimum length of [1]."` + `"[password]: value has length [0] but it must have a minimum length of [1]."` ); expect(() => bodyValidator.validate({ username: '', password: 'password' }) ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value is [] but it must have a minimum length of [1]."` + `"[username]: value has length [0] but it must have a minimum length of [1]."` ); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts index e9ba5c41c3988f..acde73dcd8190e 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts @@ -104,7 +104,7 @@ describe('Put payload schema', () => { }) ).toThrowErrorMatchingInlineSnapshot(` "[kibana.0.spaces]: types that failed validation: -- [kibana.0.spaces.0.0]: expected value to equal [*] but got [foo-*] +- [kibana.0.spaces.0.0]: expected value to equal [*] - [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" `); }); @@ -116,7 +116,7 @@ describe('Put payload schema', () => { }) ).toThrowErrorMatchingInlineSnapshot(` "[kibana.0.spaces]: types that failed validation: -- [kibana.0.spaces.0.1]: expected value to equal [*] but got [foo-space] +- [kibana.0.spaces.0.1]: expected value to equal [*] - [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" `); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index d19debe6924607..62b49f0c4e7f07 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -124,7 +124,7 @@ describe('PUT role', () => { expect(() => requestParamsSchema.validate({ name: '' }, {}, 'request params') ).toThrowErrorMatchingInlineSnapshot( - `"[request params.name]: value is [] but it must have a minimum length of [1]."` + `"[request params.name]: value has length [0] but it must have a minimum length of [1]."` ); }); @@ -132,7 +132,7 @@ describe('PUT role', () => { expect(() => requestParamsSchema.validate({ name: 'a'.repeat(1025) }, {}, 'request params') ).toThrowErrorMatchingInlineSnapshot( - `"[request params.name]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."` + `"[request params.name]: value has length [1025] but it must have a maximum length of [1024]."` ); }); }); diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index b40a4e406205c6..c2db34dc3c33cd 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -82,12 +82,12 @@ describe('Change password', () => { `"[username]: expected value of type [string] but got [undefined]"` ); expect(() => paramsSchema.validate({ username: '' })).toThrowErrorMatchingInlineSnapshot( - `"[username]: value is [] but it must have a minimum length of [1]."` + `"[username]: value has length [0] but it must have a minimum length of [1]."` ); expect(() => paramsSchema.validate({ username: 'a'.repeat(1025) }) ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."` + `"[username]: value has length [1025] but it must have a maximum length of [1024]."` ); const bodySchema = (routeConfig.validate as any).body as ObjectType; @@ -95,12 +95,12 @@ describe('Change password', () => { `"[newPassword]: expected value of type [string] but got [undefined]"` ); expect(() => bodySchema.validate({ newPassword: '' })).toThrowErrorMatchingInlineSnapshot( - `"[newPassword]: value is [] but it must have a minimum length of [1]."` + `"[newPassword]: value has length [0] but it must have a minimum length of [1]."` ); expect(() => bodySchema.validate({ newPassword: '123456', password: '' }) ).toThrowErrorMatchingInlineSnapshot( - `"[password]: value is [] but it must have a minimum length of [1]."` + `"[password]: value has length [0] but it must have a minimum length of [1]."` ); }); diff --git a/x-pack/plugins/spaces/server/lib/space_schema.test.ts b/x-pack/plugins/spaces/server/lib/space_schema.test.ts index 6330fcef19e8d1..3a4bc080f5a9b1 100644 --- a/x-pack/plugins/spaces/server/lib/space_schema.test.ts +++ b/x-pack/plugins/spaces/server/lib/space_schema.test.ts @@ -93,7 +93,7 @@ describe('#disabledFeatures', () => { disabledFeatures: 'foo', }) ).toThrowErrorMatchingInlineSnapshot( - `"[disabledFeatures]: could not parse array value from [foo]"` + `"[disabledFeatures]: could not parse array value from json input"` ); }); diff --git a/x-pack/legacy/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts similarity index 97% rename from x-pack/legacy/plugins/transform/common/constants.ts rename to x-pack/plugins/transform/common/constants.ts index 39138c12c8299e..b01a82dffa04ac 100644 --- a/x-pack/legacy/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; -import { LICENSE_TYPE_BASIC, LicenseType } from '../../../common/constants'; +import { LICENSE_TYPE_BASIC, LicenseType } from '../../../legacy/common/constants'; export const DEFAULT_REFRESH_INTERVAL_MS = 30000; export const MINIMUM_REFRESH_INTERVAL_MS = 1000; diff --git a/x-pack/plugins/transform/common/index.ts b/x-pack/plugins/transform/common/index.ts new file mode 100644 index 00000000000000..d7a791e78b3ab3 --- /dev/null +++ b/x-pack/plugins/transform/common/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface MissingPrivileges { + [key: string]: string[] | undefined; +} + +export interface Privileges { + hasAllPrivileges: boolean; + missingPrivileges: MissingPrivileges; +} + +export type TransformId = string; + +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 +export enum TRANSFORM_STATE { + ABORTING = 'aborting', + FAILED = 'failed', + INDEXING = 'indexing', + STARTED = 'started', + STOPPED = 'stopped', + STOPPING = 'stopping', +} + +export interface TransformEndpointRequest { + id: TransformId; + state?: TRANSFORM_STATE; +} + +export interface ResultData { + success: boolean; + error?: any; +} + +export interface TransformEndpointResult { + [key: string]: ResultData; +} diff --git a/x-pack/legacy/plugins/transform/common/types/common.ts b/x-pack/plugins/transform/common/types/common.ts similarity index 100% rename from x-pack/legacy/plugins/transform/common/types/common.ts rename to x-pack/plugins/transform/common/types/common.ts diff --git a/x-pack/legacy/plugins/transform/common/types/messages.ts b/x-pack/plugins/transform/common/types/messages.ts similarity index 100% rename from x-pack/legacy/plugins/transform/common/types/messages.ts rename to x-pack/plugins/transform/common/types/messages.ts diff --git a/x-pack/legacy/plugins/transform/common/utils/date_utils.ts b/x-pack/plugins/transform/common/utils/date_utils.ts similarity index 100% rename from x-pack/legacy/plugins/transform/common/utils/date_utils.ts rename to x-pack/plugins/transform/common/utils/date_utils.ts diff --git a/x-pack/legacy/plugins/transform/common/utils/es_utils.ts b/x-pack/plugins/transform/common/utils/es_utils.ts similarity index 100% rename from x-pack/legacy/plugins/transform/common/utils/es_utils.ts rename to x-pack/plugins/transform/common/utils/es_utils.ts diff --git a/x-pack/legacy/plugins/transform/common/utils/object_utils.ts b/x-pack/plugins/transform/common/utils/object_utils.ts similarity index 100% rename from x-pack/legacy/plugins/transform/common/utils/object_utils.ts rename to x-pack/plugins/transform/common/utils/object_utils.ts diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index 87e38f83ef6407..391a95853cc16c 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -2,13 +2,15 @@ "id": "transform", "version": "kibana", "server": true, - "ui": false, + "ui": true, "requiredPlugins": [ + "data", "home", "licensing", "management" ], "optionalPlugins": [ + "security", "usageCollection" ], "configPath": ["xpack", "transform"] diff --git a/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts similarity index 82% rename from x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts rename to x-pack/plugins/transform/public/__mocks__/shared_imports.ts index d7fca9820e6146..bc8ace2932c0e6 100644 --- a/x-pack/legacy/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts @@ -14,6 +14,6 @@ export const useRequest = jest.fn(() => ({ error: null, data: undefined, })); -export { mlInMemoryTableBasicFactory } from '../../../ml/public/application/components/ml_in_memory_table'; +export { mlInMemoryTableBasicFactory } from '../../../../legacy/plugins/ml/public/application/components/ml_in_memory_table'; export const SORT_DIRECTION = { ASC: 'asc' }; export const KqlFilterBar = jest.fn(() => null); diff --git a/x-pack/legacy/plugins/transform/public/app/app.tsx b/x-pack/plugins/transform/public/app/app.tsx similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/app.tsx rename to x-pack/plugins/transform/public/app/app.tsx index efbaabe447efa5..644aedec3eac08 100644 --- a/x-pack/legacy/plugins/transform/public/app/app.tsx +++ b/x-pack/plugins/transform/public/app/app.tsx @@ -14,7 +14,7 @@ import { SectionError } from './components'; import { CLIENT_BASE_PATH, SECTION_SLUG } from './constants'; import { getAppProviders } from './app_dependencies'; import { AuthorizationContext } from './lib/authorization'; -import { AppDependencies } from '../shim'; +import { AppDependencies } from './app_dependencies'; import { CloneTransformSection } from './sections/clone_transform'; import { CreateTransformSection } from './sections/create_transform'; diff --git a/x-pack/plugins/transform/public/app/app_dependencies.mock.ts b/x-pack/plugins/transform/public/app/app_dependencies.mock.ts new file mode 100644 index 00000000000000..4e5af1eca7bd02 --- /dev/null +++ b/x-pack/plugins/transform/public/app/app_dependencies.mock.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; + +import { getAppProviders, AppDependencies } from './app_dependencies'; + +const coreSetup = coreMock.createSetup(); +const coreStart = coreMock.createStart(); +const dataStart = dataPluginMock.createStartContract(); + +const appDependencies: AppDependencies = { + chrome: coreStart.chrome, + data: dataStart, + docLinks: coreStart.docLinks, + i18n: coreStart.i18n, + notifications: coreStart.notifications, + uiSettings: coreStart.uiSettings, + savedObjects: coreStart.savedObjects, + overlays: coreStart.overlays, + http: coreSetup.http, +}; + +export const Providers = getAppProviders(appDependencies); diff --git a/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx similarity index 71% rename from x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx rename to x-pack/plugins/transform/public/app/app_dependencies.tsx index 21ffbf5911a216..37258dc777d87d 100644 --- a/x-pack/legacy/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -7,27 +7,40 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { HashRouter } from 'react-router-dom'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; + import { API_BASE_PATH } from '../../common/constants'; import { setDependencyCache } from '../shared_imports'; -import { AppDependencies } from '../shim'; import { AuthorizationProvider } from './lib/authorization'; +export interface AppDependencies { + chrome: CoreStart['chrome']; + data: DataPublicPluginStart; + docLinks: CoreStart['docLinks']; + http: CoreSetup['http']; + i18n: CoreStart['i18n']; + notifications: CoreStart['notifications']; + uiSettings: CoreStart['uiSettings']; + savedObjects: CoreStart['savedObjects']; + overlays: CoreStart['overlays']; +} + let DependenciesContext: React.Context<AppDependencies>; const setAppDependencies = (deps: AppDependencies) => { const legacyBasePath = { - prepend: deps.core.http.basePath.prepend, - get: deps.core.http.basePath.get, + prepend: deps.http.basePath.prepend, + get: deps.http.basePath.get, remove: () => {}, }; setDependencyCache({ - autocomplete: deps.plugins.data.autocomplete, - docLinks: deps.core.docLinks, + autocomplete: deps.data.autocomplete, + docLinks: deps.docLinks, basePath: legacyBasePath as any, - XSRF: deps.plugins.xsrfToken, }); DependenciesContext = createContext<AppDependencies>(deps); return DependenciesContext.Provider; @@ -41,24 +54,15 @@ export const useAppDependencies = () => { return useContext<AppDependencies>(DependenciesContext); }; -export const useDocumentationLinks = () => { - const { - core: { documentation }, - } = useAppDependencies(); - return documentation; -}; - export const useToastNotifications = () => { const { - core: { - notifications: { toasts: toastNotifications }, - }, + notifications: { toasts: toastNotifications }, } = useAppDependencies(); return toastNotifications; }; export const getAppProviders = (deps: AppDependencies) => { - const I18nContext = deps.core.i18n.Context; + const I18nContext = deps.i18n.Context; // Create App dependencies context and get its provider const AppDependenciesProvider = setAppDependencies(deps); diff --git a/x-pack/legacy/plugins/transform/public/app/common/__mocks__/transform_list_row.json b/x-pack/plugins/transform/public/app/common/__mocks__/transform_list_row.json similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/__mocks__/transform_list_row.json rename to x-pack/plugins/transform/public/app/common/__mocks__/transform_list_row.json diff --git a/x-pack/legacy/plugins/transform/public/app/common/__mocks__/transform_stats.json b/x-pack/plugins/transform/public/app/common/__mocks__/transform_stats.json similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/__mocks__/transform_stats.json rename to x-pack/plugins/transform/public/app/common/__mocks__/transform_stats.json diff --git a/x-pack/legacy/plugins/transform/public/app/common/aggregations.test.ts b/x-pack/plugins/transform/public/app/common/aggregations.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/aggregations.test.ts rename to x-pack/plugins/transform/public/app/common/aggregations.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/aggregations.ts b/x-pack/plugins/transform/public/app/common/aggregations.ts similarity index 83% rename from x-pack/legacy/plugins/transform/public/app/common/aggregations.ts rename to x-pack/plugins/transform/public/app/common/aggregations.ts index 038d68ff37d876..f098e933e4b134 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/aggregations.ts +++ b/x-pack/plugins/transform/public/app/common/aggregations.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { composeValidators, patternValidator } from '../../../../ml/common/util/validators'; +import { + composeValidators, + patternValidator, +} from '../../../../../legacy/plugins/ml/common/util/validators'; export type AggName = string; diff --git a/x-pack/legacy/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/data_grid.ts rename to x-pack/plugins/transform/public/app/common/data_grid.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/dropdown.ts b/x-pack/plugins/transform/public/app/common/dropdown.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/dropdown.ts rename to x-pack/plugins/transform/public/app/common/dropdown.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/fields.ts b/x-pack/plugins/transform/public/app/common/fields.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/fields.ts rename to x-pack/plugins/transform/public/app/common/fields.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/common/index.ts rename to x-pack/plugins/transform/public/app/common/index.ts index 52a6884367bc5f..e81fadddbea69f 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -22,7 +22,6 @@ export { useRefreshTransformList, CreateRequestBody, PreviewRequestBody, - TransformId, TransformPivotConfig, IndexName, IndexPattern, @@ -35,7 +34,6 @@ export { isTransformStats, TransformStats, TRANSFORM_MODE, - TRANSFORM_STATE, } from './transform_stats'; export { getDiscoverUrl } from './navigation'; export { diff --git a/x-pack/legacy/plugins/transform/public/app/common/navigation.test.tsx b/x-pack/plugins/transform/public/app/common/navigation.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/navigation.test.tsx rename to x-pack/plugins/transform/public/app/common/navigation.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/common/navigation.tsx b/x-pack/plugins/transform/public/app/common/navigation.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/navigation.tsx rename to x-pack/plugins/transform/public/app/common/navigation.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/common/pivot_aggs.ts rename to x-pack/plugins/transform/public/app/common/pivot_aggs.ts index af55732691bb0e..3ea614aaf5c9ad 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -5,7 +5,7 @@ */ import { Dictionary } from '../../../common/types/common'; -import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import { AggName } from './aggregations'; import { EsFieldName } from './fields'; diff --git a/x-pack/legacy/plugins/transform/public/app/common/pivot_group_by.ts b/x-pack/plugins/transform/public/app/common/pivot_group_by.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/common/pivot_group_by.ts rename to x-pack/plugins/transform/public/app/common/pivot_group_by.ts index e6792958ab5d2f..bd5a5a26d9019a 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/pivot_group_by.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_group_by.ts @@ -5,7 +5,7 @@ */ import { Dictionary } from '../../../common/types/common'; -import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import { AggName } from './aggregations'; import { EsFieldName } from './fields'; diff --git a/x-pack/legacy/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/request.test.ts rename to x-pack/plugins/transform/public/app/common/request.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/common/request.ts rename to x-pack/plugins/transform/public/app/common/request.ts index 31089b86a2c2d6..79fb3acb9fcaf1 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -12,7 +12,7 @@ import { SavedSearchQuery } from '../hooks/use_search_items'; import { StepDefineExposedState } from '../sections/create_transform/components/step_define/step_define_form'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; -import { IndexPattern } from '../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../src/plugins/data/public'; import { getEsAggFromAggConfig, diff --git a/x-pack/legacy/plugins/transform/public/app/common/transform.test.ts b/x-pack/plugins/transform/public/app/common/transform.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/transform.test.ts rename to x-pack/plugins/transform/public/app/common/transform.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/transform.ts b/x-pack/plugins/transform/public/app/common/transform.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/common/transform.ts rename to x-pack/plugins/transform/public/app/common/transform.ts index 481ad3c6d74ffc..7cf7412283201a 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/transform.ts +++ b/x-pack/plugins/transform/public/app/common/transform.ts @@ -9,12 +9,13 @@ import { BehaviorSubject } from 'rxjs'; import { filter, distinctUntilChanged } from 'rxjs/operators'; import { Subscription } from 'rxjs'; +import { TransformId } from '../../../common'; + import { PivotAggDict } from './pivot_aggs'; import { PivotGroupByDict } from './pivot_group_by'; export type IndexName = string; export type IndexPattern = string; -export type TransformId = string; // Transform name must contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; // It must also start and end with an alphanumeric character. diff --git a/x-pack/legacy/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts similarity index 87% rename from x-pack/legacy/plugins/transform/public/app/common/transform_list.ts rename to x-pack/plugins/transform/public/app/common/transform_list.ts index 8925923ed9d8f1..17d729a453a05d 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TransformId, TransformPivotConfig } from './transform'; +import { TransformId } from '../../../common'; +import { TransformPivotConfig } from './transform'; import { TransformStats } from './transform_stats'; // Used to pass on attribute names to table columns diff --git a/x-pack/legacy/plugins/transform/public/app/common/transform_stats.test.ts b/x-pack/plugins/transform/public/app/common/transform_stats.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/transform_stats.test.ts rename to x-pack/plugins/transform/public/app/common/transform_stats.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/transform_stats.ts b/x-pack/plugins/transform/public/app/common/transform_stats.ts similarity index 84% rename from x-pack/legacy/plugins/transform/public/app/common/transform_stats.ts rename to x-pack/plugins/transform/public/app/common/transform_stats.ts index 433616e4228021..72df6d3985e23f 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/transform_stats.ts +++ b/x-pack/plugins/transform/public/app/common/transform_stats.ts @@ -4,18 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TransformId } from './transform'; -import { TransformListRow } from './transform_list'; +import { TransformId, TRANSFORM_STATE } from '../../../common'; -// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 -export enum TRANSFORM_STATE { - ABORTING = 'aborting', - FAILED = 'failed', - INDEXING = 'indexing', - STARTED = 'started', - STOPPED = 'stopped', - STOPPING = 'stopping', -} +import { TransformListRow } from './transform_list'; export enum TRANSFORM_MODE { BATCH = 'batch', diff --git a/x-pack/legacy/plugins/transform/public/app/common/validators.test.ts b/x-pack/plugins/transform/public/app/common/validators.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/validators.test.ts rename to x-pack/plugins/transform/public/app/common/validators.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/common/validators.ts b/x-pack/plugins/transform/public/app/common/validators.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/common/validators.ts rename to x-pack/plugins/transform/public/app/common/validators.ts diff --git a/x-pack/legacy/plugins/transform/public/app/components/index.ts b/x-pack/plugins/transform/public/app/components/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/components/index.ts rename to x-pack/plugins/transform/public/app/components/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/components/job_icon.tsx b/x-pack/plugins/transform/public/app/components/job_icon.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/components/job_icon.tsx rename to x-pack/plugins/transform/public/app/components/job_icon.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/components/section_error.tsx b/x-pack/plugins/transform/public/app/components/section_error.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/components/section_error.tsx rename to x-pack/plugins/transform/public/app/components/section_error.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/components/section_loading.tsx b/x-pack/plugins/transform/public/app/components/section_loading.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/components/section_loading.tsx rename to x-pack/plugins/transform/public/app/components/section_loading.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx b/x-pack/plugins/transform/public/app/components/toast_notification_text.test.tsx similarity index 86% rename from x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx rename to x-pack/plugins/transform/public/app/components/toast_notification_text.test.tsx index 095b57de97d9a9..5b8721cb0fe8c0 100644 --- a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx +++ b/x-pack/plugins/transform/public/app/components/toast_notification_text.test.tsx @@ -7,8 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { createPublicShim } from '../../shim'; -import { getAppProviders } from '../app_dependencies'; +import { Providers } from '../app_dependencies.mock'; import { ToastNotificationText } from './toast_notification_text'; @@ -17,7 +16,6 @@ jest.mock('ui/new_platform'); describe('ToastNotificationText', () => { test('should render the text as plain text', () => { - const Providers = getAppProviders(createPublicShim()); const props = { text: 'a short text message', }; @@ -30,7 +28,6 @@ describe('ToastNotificationText', () => { }); test('should render the text within a modal', () => { - const Providers = getAppProviders(createPublicShim()); const props = { text: 'a text message that is longer than 140 characters. a text message that is longer than 140 characters. a text message that is longer than 140 characters. ', diff --git a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx b/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx similarity index 94% rename from x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx rename to x-pack/plugins/transform/public/app/components/toast_notification_text.tsx index 4e0a0a12558d82..44927e61a42c4b 100644 --- a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx +++ b/x-pack/plugins/transform/public/app/components/toast_notification_text.tsx @@ -18,16 +18,14 @@ import { import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { useAppDependencies } from '../app_dependencies'; const MAX_SIMPLE_MESSAGE_LENGTH = 140; export const ToastNotificationText: FC<{ text: any }> = ({ text }) => { - const { - core: { overlays }, - } = useAppDependencies(); + const { overlays } = useAppDependencies(); if (typeof text === 'string' && text.length <= MAX_SIMPLE_MESSAGE_LENGTH) { return text; diff --git a/x-pack/legacy/plugins/transform/public/app/constants/index.ts b/x-pack/plugins/transform/public/app/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/constants/index.ts rename to x-pack/plugins/transform/public/app/constants/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts similarity index 93% rename from x-pack/legacy/plugins/transform/public/app/hooks/__mocks__/use_api.ts rename to x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index d3f80574922018..a5cccd58211c5d 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PreviewRequestBody, TransformId } from '../../common'; +import { TransformId, TransformEndpointRequest } from '../../../../common'; -import { TransformEndpointRequest } from '../use_api_types'; +import { PreviewRequestBody } from '../../common'; const apiFactory = () => ({ getTransforms(transformId?: TransformId): Promise<any> { diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/index.ts b/x-pack/plugins/transform/public/app/hooks/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/hooks/index.ts rename to x-pack/plugins/transform/public/app/hooks/index.ts diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts new file mode 100644 index 00000000000000..c503051ed90afb --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -0,0 +1,63 @@ +/* + * 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 { TransformId, TransformEndpointRequest, TransformEndpointResult } from '../../../common'; +import { API_BASE_PATH } from '../../../common/constants'; + +import { useAppDependencies } from '../app_dependencies'; +import { PreviewRequestBody } from '../common'; + +import { EsIndex } from './use_api_types'; + +export const useApi = () => { + const { http } = useAppDependencies(); + + return { + getTransforms(transformId?: TransformId): Promise<any> { + const transformIdString = transformId !== undefined ? `/${transformId}` : ''; + return http.get(`${API_BASE_PATH}transforms${transformIdString}`); + }, + getTransformsStats(transformId?: TransformId): Promise<any> { + if (transformId !== undefined) { + return http.get(`${API_BASE_PATH}transforms/${transformId}/_stats`); + } + + return http.get(`${API_BASE_PATH}transforms/_stats`); + }, + createTransform(transformId: TransformId, transformConfig: any): Promise<any> { + return http.put(`${API_BASE_PATH}transforms/${transformId}`, { + body: JSON.stringify(transformConfig), + }); + }, + deleteTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> { + return http.post(`${API_BASE_PATH}delete_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + getTransformsPreview(obj: PreviewRequestBody): Promise<any> { + return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); + }, + startTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> { + return http.post(`${API_BASE_PATH}start_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + stopTransforms(transformsInfo: TransformEndpointRequest[]): Promise<TransformEndpointResult> { + return http.post(`${API_BASE_PATH}stop_transforms`, { + body: JSON.stringify(transformsInfo), + }); + }, + getTransformAuditMessages(transformId: TransformId): Promise<any> { + return http.get(`${API_BASE_PATH}transforms/${transformId}/messages`); + }, + esSearch(payload: any): Promise<any> { + return http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); + }, + getIndices(): Promise<EsIndex[]> { + return http.get(`/api/index_management/indices`); + }, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.d.ts b/x-pack/plugins/transform/public/app/hooks/use_api_types.ts similarity index 84% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.d.ts rename to x-pack/plugins/transform/public/app/hooks/use_api_types.ts index d62655518f44ae..1ab320692bd5e8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/index.d.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api_types.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const TimeBuckets: any; +export interface EsIndex { + name: string; +} diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_delete_transform.tsx rename to x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index 83f456231cb856..6210c72ef9d050 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -7,14 +7,15 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; import { useToastNotifications } from '../app_dependencies'; import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; import { ToastNotificationText } from '../components'; import { useApi } from './use_api'; -import { TransformEndpointRequest, TransformEndpointResult } from './use_api_types'; export const useDeleteTransforms = () => { const toastNotifications = useToastNotifications(); diff --git a/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts b/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts new file mode 100644 index 00000000000000..7589ee0a3e935f --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_documentation_links.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useAppDependencies } from '../app_dependencies'; + +import { TRANSFORM_DOC_PATHS } from '../constants'; + +export const useDocumentationLinks = () => { + const deps = useAppDependencies(); + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = deps.docLinks; + return { + esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`, + esIndicesCreateIndex: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/indices-create-index.html#indices-create-index`, + esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`, + esQueryDsl: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, + esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, + esTransform: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/${TRANSFORM_DOC_PATHS.transforms}`, + esTransformPivot: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/put-transform.html#put-transform-request-body`, + mlDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/`, + }; +}; diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_get_transforms.ts rename to x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_request.ts b/x-pack/plugins/transform/public/app/hooks/use_request.ts similarity index 89% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_request.ts rename to x-pack/plugins/transform/public/app/hooks/use_request.ts index 8c489048a77ef8..e1bdbce941eb00 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_request.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_request.ts @@ -9,8 +9,6 @@ import { UseRequestConfig, useRequest as _useRequest } from '../../shared_import import { useAppDependencies } from '../app_dependencies'; export const useRequest = (config: UseRequestConfig) => { - const { - core: { http }, - } = useAppDependencies(); + const { http } = useAppDependencies(); return _useRequest(http, config); }; diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts rename to x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 2258f8f33f01dd..c536e70fd292f6 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -10,7 +10,7 @@ import { esQuery, IndexPatternsContract, IndexPatternAttributes, -} from '../../../../../../../../src/plugins/data/public'; +} from '../../../../../../../src/plugins/data/public'; import { matchAllQuery } from '../../common'; diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts rename to x-pack/plugins/transform/public/app/hooks/use_search_items/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts similarity index 89% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts rename to x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 12fc75c20ffa48..f5f9e98fe659c1 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -23,14 +23,14 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [savedObjectId, setSavedObjectId] = useState(defaultSavedObjectId); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.plugins.data.indexPatterns; - const uiSettings = appDeps.core.uiSettings; - const savedObjectsClient = appDeps.core.savedObjects.client; + const indexPatterns = appDeps.data.indexPatterns; + const uiSettings = appDeps.uiSettings; + const savedObjectsClient = appDeps.savedObjects.client; const savedSearches = createSavedSearchesLoader({ savedObjectsClient, indexPatterns, - chrome: appDeps.core.chrome, - overlays: appDeps.core.overlays, + chrome: appDeps.chrome, + overlays: appDeps.overlays, }); const [searchItems, setSearchItems] = useState<SearchItems | undefined>(undefined); diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_start_transform.ts b/x-pack/plugins/transform/public/app/hooks/use_start_transform.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_start_transform.ts rename to x-pack/plugins/transform/public/app/hooks/use_start_transform.ts index f460d8200c6e44..8e966918e4502a 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_start_transform.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_start_transform.ts @@ -6,11 +6,12 @@ import { i18n } from '@kbn/i18n'; +import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; + import { useToastNotifications } from '../app_dependencies'; import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; import { useApi } from './use_api'; -import { TransformEndpointRequest, TransformEndpointResult } from './use_api_types'; export const useStartTransforms = () => { const toastNotifications = useToastNotifications(); diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_stop_transform.ts b/x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_stop_transform.ts rename to x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts index 758c574a3f7cdc..03bc9b1ea39980 100644 --- a/x-pack/legacy/plugins/transform/public/app/hooks/use_stop_transform.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_stop_transform.ts @@ -6,11 +6,12 @@ import { i18n } from '@kbn/i18n'; +import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; + import { useToastNotifications } from '../app_dependencies'; import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; import { useApi } from './use_api'; -import { TransformEndpointRequest, TransformEndpointResult } from './use_api_types'; export const useStopTransforms = () => { const toastNotifications = useToastNotifications(); diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_x_json_mode.ts b/x-pack/plugins/transform/public/app/hooks/use_x_json_mode.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/hooks/use_x_json_mode.ts rename to x-pack/plugins/transform/public/app/hooks/use_x_json_mode.ts diff --git a/x-pack/legacy/plugins/transform/public/app/index.scss b/x-pack/plugins/transform/public/app/index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/index.scss rename to x-pack/plugins/transform/public/app/index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx rename to x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx index dde63710f56aae..6553d4474d3928 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx @@ -5,8 +5,12 @@ */ import React, { createContext } from 'react'; + +import { Privileges } from '../../../../../common'; + import { useRequest } from '../../../hooks'; -import { hasPrivilegeFactory, Capabilities, Privileges } from './common'; + +import { hasPrivilegeFactory, Capabilities } from './common'; interface Authorization { isLoading: boolean; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/common.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts similarity index 94% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/components/common.ts rename to x-pack/plugins/transform/public/app/lib/authorization/components/common.ts index 27556e0d673a8e..282a737d0bf1e7 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/common.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts @@ -6,6 +6,8 @@ import { i18n } from '@kbn/i18n'; +import { Privileges } from '../../../../../common'; + export interface Capabilities { canGetTransform: boolean; canDeleteTransform: boolean; @@ -16,11 +18,6 @@ export interface Capabilities { export type Privilege = [string, string]; -export interface Privileges { - hasAllPrivileges: boolean; - missingPrivileges: MissingPrivileges; -} - function isPrivileges(arg: any): arg is Privileges { return ( typeof arg === 'object' && @@ -33,9 +30,6 @@ function isPrivileges(arg: any): arg is Privileges { ); } -export interface MissingPrivileges { - [key: string]: string[] | undefined; -} export const toArray = (value: string | string[]): string[] => Array.isArray(value) ? value : [value]; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/index.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/index.ts similarity index 86% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/components/index.ts rename to x-pack/plugins/transform/public/app/lib/authorization/components/index.ts index 9b37fa1b4393d9..29390ab34ea798 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/index.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createCapabilityFailureMessage, Privileges } from './common'; +export { createCapabilityFailureMessage } from './common'; export { AuthorizationProvider, AuthorizationContext } from './authorization_provider'; export { PrivilegesWrapper } from './with_privileges'; export { NotAuthorizedSection } from './not_authorized_section'; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/not_authorized_section.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/not_authorized_section.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/components/not_authorized_section.tsx rename to x-pack/plugins/transform/public/app/lib/authorization/components/not_authorized_section.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx similarity index 96% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx rename to x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx index 91e5be53312037..1469e2a471cc6d 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/with_privileges.tsx @@ -10,11 +10,13 @@ import { EuiPageContent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MissingPrivileges } from '../../../../../common'; + import { SectionLoading } from '../../../components'; import { AuthorizationContext } from './authorization_provider'; import { NotAuthorizedSection } from './not_authorized_section'; -import { hasPrivilegeFactory, toArray, MissingPrivileges, Privilege } from './common'; +import { hasPrivilegeFactory, toArray, Privilege } from './common'; interface Props { /** diff --git a/x-pack/legacy/plugins/transform/public/app/lib/authorization/index.ts b/x-pack/plugins/transform/public/app/lib/authorization/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/lib/authorization/index.ts rename to x-pack/plugins/transform/public/app/lib/authorization/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx rename to x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 4618e96cbfd6eb..373faee3b46ed3 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -22,11 +22,12 @@ import { } from '@elastic/eui'; import { useApi } from '../../hooks/use_api'; +import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useSearchItems } from '../../hooks/use_search_items'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { useAppDependencies, useDocumentationLinks } from '../../app_dependencies'; +import { useAppDependencies } from '../../app_dependencies'; import { TransformPivotConfig } from '../../common'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; @@ -65,8 +66,8 @@ export const CloneTransformSection: FC<Props> = ({ match }) => { const api = useApi(); const appDeps = useAppDependencies(); - const savedObjectsClient = appDeps.core.savedObjects.client; - const indexPatterns = appDeps.plugins.data.indexPatterns; + const savedObjectsClient = appDeps.savedObjects.client; + const indexPatterns = appDeps.data.indexPatterns; const { esTransform } = useDocumentationLinks(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/index.ts b/x-pack/plugins/transform/public/app/sections/clone_transform/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/clone_transform/index.ts rename to x-pack/plugins/transform/public/app/sections/clone_transform/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_summary.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_summary.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_summary.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_summary.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_aggregation_label_form.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_aggregation_label_form.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_aggregation_label_form.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_aggregation_label_form.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_index.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_index.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/agg_label_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_summary.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_summary.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_summary.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/group_by_label_summary.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_summary.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_summary.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_summary.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/list_summary.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/popover_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/popover_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/popover_form.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/__snapshots__/popover_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/_group_by_label_form.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/_group_by_label_form.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/_group_by_label_form.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/_group_by_label_form.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/_index.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/_index.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/group_by_label_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/list_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/group_by_list/popover_form.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/expanded_row.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/expanded_row.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/expanded_row.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/expanded_row.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.test.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.test.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.test.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/common.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx similarity index 80% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx index 48eff132cd7532..7a1532705916f2 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx @@ -5,11 +5,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, wait } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { getPivotQuery } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; @@ -19,7 +18,8 @@ jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <SourceIndexPreview />', () => { - test('Minimal initialization', () => { + // Using the async/await wait()/done() pattern to avoid act() errors. + test('Minimal initialization', async done => { // Arrange const props = { indexPattern: { @@ -28,7 +28,6 @@ describe('Transform: <SourceIndexPreview />', () => { } as SearchItems['indexPattern'], query: getPivotQuery('the-query'), }; - const Providers = getAppProviders(createPublicShim()); const { getByText } = render( <Providers> <SourceIndexPreview {...props} /> @@ -38,5 +37,7 @@ describe('Transform: <SourceIndexPreview />', () => { // Act // Assert expect(getByText(`Source index ${props.indexPattern.title}`)).toBeInTheDocument(); + await wait(); + done(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx new file mode 100644 index 00000000000000..9992f153f3b864 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import '@testing-library/jest-dom/extend-expect'; + +import { SimpleQuery } from '../../../../common'; +import { + SOURCE_INDEX_STATUS, + useSourceIndexData, + UseSourceIndexDataReturnType, +} from './use_source_index_data'; + +jest.mock('../../../../hooks/use_api'); + +const query: SimpleQuery = { + query_string: { + query: '*', + default_operator: 'AND', + }, +}; + +describe('useSourceIndexData', () => { + test('indexPattern set triggers loading', async done => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceIndexData({ id: 'the-id', title: 'the-title', fields: [] }, query, { + pageIndex: 0, + pageSize: 10, + }) + ); + const sourceIndexObj: UseSourceIndexDataReturnType = result.current; + + await waitForNextUpdate(); + + expect(sourceIndexObj.errorMessage).toBe(''); + expect(sourceIndexObj.status).toBe(SOURCE_INDEX_STATUS.LOADING); + expect(sourceIndexObj.tableItems).toEqual([]); + done(); + }); + + // TODO add more tests to check data retrieved via `api.esSearch()`. + // This needs more investigation in regards to jest's React Hooks support. +}); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx similarity index 85% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx index 7a22af492e36eb..6223dfc5623b7b 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { StepCreateForm } from './step_create_form'; @@ -27,7 +26,6 @@ describe('Transform: <StepCreateForm />', () => { onChange() {}, }; - const Providers = getAppProviders(createPublicShim()); const { getByText } = render( <Providers> <StepCreateForm {...props} /> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 4198c2ea0260d7..49be2e67ce552d 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -28,7 +28,7 @@ import { EuiText, } from '@elastic/eui'; -import { toMountPoint } from '../../../../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants'; @@ -75,8 +75,8 @@ export const StepCreateForm: FC<Props> = React.memo( ); const deps = useAppDependencies(); - const indexPatterns = deps.plugins.data.indexPatterns; - const uiSettings = deps.core.uiSettings; + const indexPatterns = deps.data.indexPatterns; + const uiSettings = deps.uiSettings; const toastNotifications = useToastNotifications(); useEffect(() => { @@ -464,7 +464,7 @@ export const StepCreateForm: FC<Props> = React.memo( defaultMessage: 'Use Discover to explore the transform.', } )} - href={getDiscoverUrl(indexPatternId, deps.core.http.basePath.get())} + href={getDiscoverUrl(indexPatternId, deps.http.basePath.get())} data-test-subj="transformWizardCardDiscover" /> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts index 88e009c63339aa..c9a52304578ee1 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts @@ -20,7 +20,7 @@ import { getPivotPreviewDevConsoleStatement, getPivotDropdownOptions, } from './common'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; describe('Transform: Define Pivot Common', () => { test('customSortFactory()', () => { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts index 35e1ea02a5cefe..0779cb1339af69 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts @@ -5,10 +5,7 @@ */ import { get } from 'lodash'; import { EuiComboBoxOptionOption, EuiDataGridSorting } from '@elastic/eui'; -import { - IndexPattern, - KBN_FIELD_TYPES, -} from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; import { getNestedProperty } from '../../../../../../common/utils/object_utils'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx similarity index 86% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx index 464b6e1fd9fe39..f39885f520995b 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx @@ -5,11 +5,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, wait } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { getPivotQuery, PivotAggsConfig, @@ -25,7 +24,8 @@ jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <PivotPreview />', () => { - test('Minimal initialization', () => { + // Using the async/await wait()/done() pattern to avoid act() errors. + test('Minimal initialization', async done => { // Arrange const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, @@ -49,7 +49,6 @@ describe('Transform: <PivotPreview />', () => { query: getPivotQuery('the-query'), }; - const Providers = getAppProviders(createPublicShim()); const { getByText } = render( <Providers> <PivotPreview {...props} /> @@ -59,5 +58,7 @@ describe('Transform: <PivotPreview />', () => { // Act // Assert expect(getByText('Transform pivot preview')).toBeInTheDocument(); + await wait(); + done(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index f45ef7cfddbf9d..d5cffad1668311 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -5,11 +5,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, wait } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { PivotAggsConfigDict, PivotGroupByConfigDict, @@ -24,7 +23,8 @@ jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <DefinePivotForm />', () => { - test('Minimal initialization', () => { + // Using the async/await wait()/done() pattern to avoid act() errors. + test('Minimal initialization', async done => { // Arrange const searchItems = { indexPattern: { @@ -32,7 +32,6 @@ describe('Transform: <DefinePivotForm />', () => { fields: [] as any[], } as SearchItems['indexPattern'], }; - const Providers = getAppProviders(createPublicShim()); const { getByLabelText } = render( <Providers> <StepDefineForm onChange={jest.fn()} searchItems={searchItems as SearchItems} /> @@ -42,6 +41,8 @@ describe('Transform: <DefinePivotForm />', () => { // Act // Assert expect(getByLabelText('Index pattern')).toBeInTheDocument(); + await wait(); + done(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx similarity index 99% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index f61f54c38680ef..254d867165ae69 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -26,9 +26,10 @@ import { EuiSwitch, } from '@elastic/eui'; +import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items'; import { useXJsonMode, xJsonMode } from '../../../../hooks/use_x_json_mode'; -import { useDocumentationLinks, useToastNotifications } from '../../../../app_dependencies'; +import { useToastNotifications } from '../../../../app_dependencies'; import { TransformPivotConfig } from '../../../../common'; import { dictionaryToArray, Dictionary } from '../../../../../../common/types/common'; import { DropDown } from '../aggregation_dropdown'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx similarity index 88% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 0f7da50bbbadee..36a662e0cb7e64 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -5,11 +5,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, wait } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { PivotAggsConfig, PivotGroupByConfig, @@ -25,7 +24,8 @@ jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: <DefinePivotSummary />', () => { - test('Minimal initialization', () => { + // Using the async/await wait()/done() pattern to avoid act() errors. + test('Minimal initialization', async done => { // Arrange const searchItems = { indexPattern: { @@ -56,7 +56,6 @@ describe('Transform: <DefinePivotSummary />', () => { valid: true, }; - const Providers = getAppProviders(createPublicShim()); const { getByText } = render( <Providers> <StepDefineSummary formState={formState} searchItems={searchItems as SearchItems} /> @@ -67,5 +66,7 @@ describe('Transform: <DefinePivotSummary />', () => { // Assert expect(getByText('Group by')).toBeInTheDocument(); expect(getByText('Aggregations')).toBeInTheDocument(); + await wait(); + done(); }); }); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/switch_modal.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx similarity index 96% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx index 1ad8ed099b2414..3e972e9f92e72b 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.test.tsx @@ -14,7 +14,7 @@ import { UsePivotPreviewDataReturnType, } from './use_pivot_preview_data'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; jest.mock('../../../../hooks/use_api'); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts similarity index 96% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts index 84fafcad8151e9..215435027d5b85 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/use_pivot_preview_data.ts @@ -10,10 +10,7 @@ import { dictionaryToArray } from '../../../../../../common/types/common'; import { useApi } from '../../../../hooks/use_api'; import { Dictionary } from '../../../../../../common/types/common'; -import { - IndexPattern, - ES_FIELD_TYPES, -} from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPattern, ES_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; import { getPreviewRequestBody, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index ea9483af49302f..e56a519f808035 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -10,19 +10,17 @@ import { i18n } from '@kbn/i18n'; import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; -import { toMountPoint } from '../../../../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { TransformId } from '../../../../../../common'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; -import { - useAppDependencies, - useDocumentationLinks, - useToastNotifications, -} from '../../../../app_dependencies'; +import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { ToastNotificationText } from '../../../../components'; +import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; import { SearchItems } from '../../../../hooks/use_search_items'; import { useApi } from '../../../../hooks/use_api'; -import { isTransformIdValid, TransformId, TransformPivotConfig } from '../../../../common'; +import { isTransformIdValid, TransformPivotConfig } from '../../../../common'; import { EsIndexName, IndexPatternTitle } from './common'; import { delayValidator } from '../../../../common/validators'; @@ -132,7 +130,7 @@ export const StepDetailsForm: FC<Props> = React.memo( } try { - setIndexPatternTitles(await deps.plugins.data.indexPatterns.getTitles()); + setIndexPatternTitles(await deps.data.indexPatterns.getTitles()); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_index.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_index.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard_nav/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard_nav/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard_nav/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard_nav/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard_nav/wizard_nav.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard_nav/wizard_nav.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard_nav/wizard_nav.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/components/wizard_nav/wizard_nav.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx rename to x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index d09fc0913590e9..eaf1e09df47546 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -21,7 +21,7 @@ import { import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { useDocumentationLinks } from '../../app_dependencies'; +import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useSearchItems } from '../../hooks/use_search_items'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/index.ts b/x-pack/plugins/transform/public/app/sections/create_transform/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/create_transform/index.ts rename to x-pack/plugins/transform/public/app/sections/create_transform/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/__snapshots__/transform_management_section.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/__snapshots__/transform_management_section.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/__snapshots__/transform_management_section.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/__snapshots__/transform_management_section.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/__snapshots__/create_transform_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/__snapshots__/create_transform_button.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/__snapshots__/create_transform_button.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/__snapshots__/create_transform_button.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_index.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_index.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_transform_search_dialog.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_transform_search_dialog.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_transform_search_dialog.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/_transform_search_dialog.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/create_transform_button.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/create_transform_button/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/create_transform_button/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/refresh_transform_list_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/refresh_transform_list_button.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/refresh_transform_list_button.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/refresh_transform_list_button/refresh_transform_list_button.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/search_selection/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/search_selection/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx index ff8bb7e2f432d7..7d6b178e4bfe46 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx @@ -8,7 +8,7 @@ import { EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui' import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; -import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public'; +import { SavedObjectFinderUi } from '../../../../../../../../../src/plugins/saved_objects/public'; import { useAppDependencies } from '../../../../app_dependencies'; interface SearchSelectionProps { @@ -18,9 +18,8 @@ interface SearchSelectionProps { const fixedPageSize: number = 8; export const SearchSelection: FC<SearchSelectionProps> = ({ onSearchSelected }) => { - const { - core: { uiSettings, savedObjects }, - } = useAppDependencies(); + const { uiSettings, savedObjects } = useAppDependencies(); + return ( <> <EuiModalHeader> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_index.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_index.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stat.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stat.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stat.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stat.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stats_bar.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stats_bar.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stats_bar.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/_stats_bar.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/stat.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/stat.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/stat.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/stat.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/stats_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/stats_bar.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/stats_bar/stats_bar.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/stats_bar/stats_bar.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_delete.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_delete.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_delete.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_delete.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_start.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_start.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_start.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_start.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_stop.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_stop.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_stop.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/action_stop.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/transform_list.test.tsx.snap diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_index.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/_transform_table.scss diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_clone.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_clone.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_clone.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_clone.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx similarity index 85% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx index 82b9f0a292bb97..17cca6afae4833 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.test.tsx @@ -7,8 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { TransformListRow } from '../../../../common'; import { DeleteAction } from './action_delete'; @@ -20,8 +19,6 @@ jest.mock('../../../../../shared_imports'); describe('Transform: Transform List Actions <DeleteAction />', () => { test('Minimal initialization', () => { - const Providers = getAppProviders(createPublicShim()); - const item: TransformListRow = transformListRow; const props = { disabled: false, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx index 08db7a608be2cf..c20feba29f5822 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx @@ -14,14 +14,14 @@ import { EUI_MODAL_CONFIRM_BUTTON, } from '@elastic/eui'; -import { useDeleteTransforms } from '../../../../hooks'; +import { TRANSFORM_STATE } from '../../../../../../common'; +import { useDeleteTransforms } from '../../../../hooks'; import { createCapabilityFailureMessage, AuthorizationContext, } from '../../../../lib/authorization'; - -import { TransformListRow, TRANSFORM_STATE } from '../../../../common'; +import { TransformListRow } from '../../../../common'; interface DeleteActionProps { items: TransformListRow[]; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx similarity index 85% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx index 002b4ea19f9678..bbdfdbbc3c121f 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.test.tsx @@ -7,8 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { TransformListRow } from '../../../../common'; import { StartAction } from './action_start'; @@ -20,8 +19,6 @@ jest.mock('../../../../../shared_imports'); describe('Transform: Transform List Actions <StartAction />', () => { test('Minimal initialization', () => { - const Providers = getAppProviders(createPublicShim()); - const item: TransformListRow = transformListRow; const props = { disabled: false, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx similarity index 97% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx index c81661f2184431..b6b8a75b4b735b 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_start.tsx @@ -14,14 +14,14 @@ import { EUI_MODAL_CONFIRM_BUTTON, } from '@elastic/eui'; -import { useStartTransforms } from '../../../../hooks'; +import { TRANSFORM_STATE } from '../../../../../../common'; +import { useStartTransforms } from '../../../../hooks'; import { createCapabilityFailureMessage, AuthorizationContext, } from '../../../../lib/authorization'; - -import { TransformListRow, isCompletedBatchTransform, TRANSFORM_STATE } from '../../../../common'; +import { TransformListRow, isCompletedBatchTransform } from '../../../../common'; interface StartActionProps { items: TransformListRow[]; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx similarity index 85% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx index e2a22765dfb981..840fbc4b9034b0 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.test.tsx @@ -7,8 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { createPublicShim } from '../../../../../shim'; -import { getAppProviders } from '../../../../app_dependencies'; +import { Providers } from '../../../../app_dependencies.mock'; import { TransformListRow } from '../../../../common'; import { StopAction } from './action_stop'; @@ -20,8 +19,6 @@ jest.mock('../../../../../shared_imports'); describe('Transform: Transform List Actions <StopAction />', () => { test('Minimal initialization', () => { - const Providers = getAppProviders(createPublicShim()); - const item: TransformListRow = transformListRow; const props = { disabled: false, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx index 98aec0acf0bf68..f6b63191b538d6 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_stop.tsx @@ -8,7 +8,9 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { TransformListRow, TRANSFORM_STATE } from '../../../../common'; +import { TRANSFORM_STATE } from '../../../../../../common'; + +import { TransformListRow } from '../../../../common'; import { createCapabilityFailureMessage, AuthorizationContext, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx similarity index 90% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx index 3e3829973e328e..6a55b419e74a93 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx @@ -5,7 +5,11 @@ */ import React from 'react'; -import { TransformListRow, TRANSFORM_STATE } from '../../../../common'; + +import { TRANSFORM_STATE } from '../../../../../../common'; + +import { TransformListRow } from '../../../../common'; + import { CloneAction } from './action_clone'; import { StartAction } from './action_start'; import { StopAction } from './action_stop'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx similarity index 99% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx index 53627d1cf2f6b3..fb24ff2a12e022 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx @@ -19,6 +19,8 @@ import { RIGHT_ALIGNMENT, } from '@elastic/eui'; +import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; + import { ActionsColumnType, ComputedColumnType, @@ -28,11 +30,9 @@ import { import { getTransformProgress, - TransformId, TransformListRow, TransformStats, TRANSFORM_LIST_COLUMN, - TRANSFORM_STATE, } from '../../../../common'; import { getActions } from './actions'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts similarity index 88% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts index c2030e814aa95a..11e4dc3dfa2b8d 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.test.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TRANSFORM_STATE } from '../../../../../../common'; + import mockTransformListRow from '../../../../common/__mocks__/transform_list_row.json'; -import { TransformListRow, isCompletedBatchTransform, TRANSFORM_STATE } from '../../../../common'; +import { TransformListRow, isCompletedBatchTransform } from '../../../../common'; describe('Transform: isCompletedBatchTransform()', () => { test('isCompletedBatchTransform()', () => { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/common.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/common.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/common.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_json_pane.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_messages_pane.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx index e480381d6fb8e6..0e9b531e1feafe 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx @@ -19,7 +19,7 @@ import { PreviewRequestBody, TransformPivotConfig, } from '../../../../common'; -import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; +import { ES_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils'; import { transformTableFactory } from './transform_table'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx similarity index 99% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index 6bea4e5750d2de..9c2da53c36d6bf 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -21,15 +21,15 @@ import { Direction, } from '@elastic/eui'; +import { TransformId, TRANSFORM_STATE } from '../../../../../../common'; + import { OnTableChangeArg, SortDirection, SORT_DIRECTION } from '../../../../../shared_imports'; import { useRefreshTransformList, - TransformId, TransformListRow, TRANSFORM_MODE, TRANSFORM_LIST_COLUMN, - TRANSFORM_STATE, } from '../../../../common'; import { AuthorizationContext } from '../../../../lib/authorization'; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_table.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx similarity index 95% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx index dd2369e199d0dc..5f05d08734efe1 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transforms_stats_bar.tsx @@ -6,8 +6,12 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; + +import { TRANSFORM_STATE } from '../../../../../../common'; + +import { TRANSFORM_MODE, TransformListRow } from '../../../../common'; + import { StatsBar, TransformStatsBarStats } from '../stats_bar'; -import { TRANSFORM_STATE, TRANSFORM_MODE, TransformListRow } from '../../../../common'; function createTranformStats(transformsList: TransformListRow[]) { const transformStats = { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/use_refresh_interval.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_refresh_interval.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/components/transform_list/use_refresh_interval.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_refresh_interval.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/index.ts rename to x-pack/plugins/transform/public/app/sections/transform_management/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.test.tsx similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.test.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.test.tsx diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx similarity index 98% rename from x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx rename to x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 8c174098fb623d..002c418b3d2e36 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -22,8 +22,9 @@ import { } from '@elastic/eui'; import { APP_GET_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { useDocumentationLinks } from '../../app_dependencies'; + import { useRefreshTransformList, TransformListRow } from '../../common'; +import { useDocumentationLinks } from '../../hooks/use_documentation_links'; import { useGetTransforms } from '../../hooks'; import { RedirectToCreateTransform } from '../../common/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; diff --git a/x-pack/legacy/plugins/transform/public/app/services/navigation/breadcrumb.ts b/x-pack/plugins/transform/public/app/services/navigation/breadcrumb.ts similarity index 91% rename from x-pack/legacy/plugins/transform/public/app/services/navigation/breadcrumb.ts rename to x-pack/plugins/transform/public/app/services/navigation/breadcrumb.ts index aa8041a1cbe239..6637b8a39cd567 100644 --- a/x-pack/legacy/plugins/transform/public/app/services/navigation/breadcrumb.ts +++ b/x-pack/plugins/transform/public/app/services/navigation/breadcrumb.ts @@ -7,12 +7,11 @@ import { textService } from '../text'; import { linkToHome } from './links'; -import { ManagementAppMountParams } from '../../../../../../../../src/plugins/management/public'; +import { ManagementAppMountParams } from '../../../../../../../src/plugins/management/public'; type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; export enum BREADCRUMB_SECTION { - MANAGEMENT = 'management', HOME = 'home', CLONE_TRANSFORM = 'cloneTransform', CREATE_TRANSFORM = 'createTransform', @@ -29,7 +28,6 @@ type Breadcrumbs = { class BreadcrumbService { private breadcrumbs: Breadcrumbs = { - management: [], home: [], cloneTransform: [], createTransform: [], @@ -41,7 +39,6 @@ class BreadcrumbService { // Home and sections this.breadcrumbs.home = [ - ...this.breadcrumbs.management, { text: textService.breadcrumbs.home, href: linkToHome(), diff --git a/x-pack/legacy/plugins/transform/public/app/services/navigation/doc_title.ts b/x-pack/plugins/transform/public/app/services/navigation/doc_title.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/services/navigation/doc_title.ts rename to x-pack/plugins/transform/public/app/services/navigation/doc_title.ts diff --git a/x-pack/legacy/plugins/transform/public/app/services/navigation/index.ts b/x-pack/plugins/transform/public/app/services/navigation/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/services/navigation/index.ts rename to x-pack/plugins/transform/public/app/services/navigation/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/services/navigation/links.ts b/x-pack/plugins/transform/public/app/services/navigation/links.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/services/navigation/links.ts rename to x-pack/plugins/transform/public/app/services/navigation/links.ts diff --git a/x-pack/legacy/plugins/transform/public/app/services/text/index.ts b/x-pack/plugins/transform/public/app/services/text/index.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/services/text/index.ts rename to x-pack/plugins/transform/public/app/services/text/index.ts diff --git a/x-pack/legacy/plugins/transform/public/app/services/text/text.ts b/x-pack/plugins/transform/public/app/services/text/text.ts similarity index 100% rename from x-pack/legacy/plugins/transform/public/app/services/text/text.ts rename to x-pack/plugins/transform/public/app/services/text/text.ts diff --git a/x-pack/plugins/transform/public/index.ts b/x-pack/plugins/transform/public/index.ts new file mode 100644 index 00000000000000..83b5addbc5b2c3 --- /dev/null +++ b/x-pack/plugins/transform/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import './app/index.scss'; +import { TransformUiPlugin } from './plugin'; + +/** @public */ +export const plugin = () => { + return new TransformUiPlugin(); +}; diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts new file mode 100644 index 00000000000000..1a34e7a6413657 --- /dev/null +++ b/x-pack/plugins/transform/public/plugin.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n as kbnI18n } from '@kbn/i18n'; + +import { CoreSetup } from 'src/core/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { ManagementSetup } from 'src/plugins/management/public'; + +import { renderApp } from './app/app'; +import { AppDependencies } from './app/app_dependencies'; +import { breadcrumbService } from './app/services/navigation'; +import { docTitleService } from './app/services/navigation'; +import { textService } from './app/services/text'; + +export interface PluginsDependencies { + data: DataPublicPluginStart; + management: ManagementSetup; +} + +export class TransformUiPlugin { + public setup(coreSetup: CoreSetup<PluginsDependencies>, pluginsSetup: PluginsDependencies): void { + const { management } = pluginsSetup; + + // Register management section + const esSection = management.sections.getSection('elasticsearch'); + if (esSection !== undefined) { + esSection.registerApp({ + id: 'transform', + title: kbnI18n.translate('xpack.transform.appTitle', { + defaultMessage: 'Transforms', + }), + order: 3, + mount: async ({ element, setBreadcrumbs }) => { + const { http, notifications, getStartServices } = coreSetup; + const startServices = await getStartServices(); + const [core, plugins] = startServices; + const { chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; + const { data } = plugins; + const { docTitle } = chrome; + + // Initialize services + textService.init(); + docTitleService.init(docTitle.change); + breadcrumbService.setup(setBreadcrumbs); + + // AppCore/AppPlugins to be passed on as React context + const appDependencies: AppDependencies = { + chrome, + data, + docLinks, + http, + i18n, + notifications, + overlays, + savedObjects, + uiSettings, + }; + + return renderApp(element, appDependencies); + }, + }); + } + } + + public start() {} + public stop() {} +} diff --git a/x-pack/legacy/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts similarity index 56% rename from x-pack/legacy/plugins/transform/public/shared_imports.ts rename to x-pack/plugins/transform/public/shared_imports.ts index 1ca71f8c4aa773..3582dd5d266e2f 100644 --- a/x-pack/legacy/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createSavedSearchesLoader } from '../../../../../src/plugins/discover/public'; -export { XJsonMode } from '../../../../plugins/es_ui_shared/console_lang/ace/modes/x_json'; +export { createSavedSearchesLoader } from '../../../../src/plugins/discover/public'; +export { XJsonMode } from '../../es_ui_shared/console_lang/ace/modes/x_json'; export { collapseLiteralStrings, expandLiteralStrings, -} from '../../../../../src/plugins/es_ui_shared/console_lang/lib'; +} from '../../../../src/plugins/es_ui_shared/console_lang/lib'; export { SendRequestConfig, @@ -17,12 +17,12 @@ export { UseRequestConfig, sendRequest, useRequest, -} from '../../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; +} from '../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; export { CronEditor, DAY, -} from '../../../../../src/plugins/es_ui_shared/public/components/cron_editor'; +} from '../../../../src/plugins/es_ui_shared/public/components/cron_editor'; // Custom version of EuiInMemoryTable with TypeScript // support and a fix for updating sorting props. @@ -37,10 +37,10 @@ export { SortingPropType, SortDirection, SORT_DIRECTION, -} from '../../ml/public/application/components/ml_in_memory_table'; +} from '../../../legacy/plugins/ml/public/application/components/ml_in_memory_table'; // Needs to be imported because we're reusing KqlFilterBar which depends on it. -export { setDependencyCache } from '../../ml/public/application/util/dependency_cache'; +export { setDependencyCache } from '../../../legacy/plugins/ml/public/application/util/dependency_cache'; // @ts-ignore: could not find declaration file for module -export { KqlFilterBar } from '../../ml/public/application/components/kql_filter_bar'; +export { KqlFilterBar } from '../../../legacy/plugins/ml/public/application/components/kql_filter_bar'; diff --git a/x-pack/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts index d09152bf1a6031..295375794c04ef 100644 --- a/x-pack/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -10,10 +10,7 @@ import { i18n } from '@kbn/i18n'; import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; -import { - TransformEndpointRequest, - TransformEndpointResult, -} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; +import { TransformEndpointRequest, TransformEndpointResult } from '../../../common'; const REQUEST_TIMEOUT = 'RequestTimeout'; diff --git a/x-pack/plugins/transform/server/routes/api/privileges.ts b/x-pack/plugins/transform/server/routes/api/privileges.ts index 6003a88ffa40c2..9d7fb16ecc19aa 100644 --- a/x-pack/plugins/transform/server/routes/api/privileges.ts +++ b/x-pack/plugins/transform/server/routes/api/privileges.ts @@ -3,13 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - APP_CLUSTER_PRIVILEGES, - APP_INDEX_PRIVILEGES, -} from '../../../../../legacy/plugins/transform/common/constants'; -// NOTE: now we import it from our "public" folder, but when the Authorisation lib -// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder -import { Privileges } from '../../../../../legacy/plugins/transform/public/app/lib/authorization'; +import { APP_CLUSTER_PRIVILEGES, APP_INDEX_PRIVILEGES } from '../../../common/constants'; +import { Privileges } from '../../../common'; import { RouteDependencies } from '../../types'; import { addBasePath } from '../index'; diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 7aaae1f1c70397..bf201323a3c2f4 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -9,12 +9,12 @@ import { RequestHandler } from 'kibana/server'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; -import { TRANSFORM_STATE } from '../../../../../legacy/plugins/transform/public/app/common'; import { TransformEndpointRequest, TransformEndpointResult, -} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; -import { TransformId } from '../../../../../legacy/plugins/transform/public/app/common/transform'; + TransformId, + TRANSFORM_STATE, +} from '../../../common'; import { RouteDependencies } from '../../types'; @@ -273,9 +273,7 @@ const previewTransformHandler: RequestHandler = async (ctx, req, res) => { }; const startTransformsHandler: RequestHandler = async (ctx, req, res) => { - const { transformsInfo } = req.body as { - transformsInfo: TransformEndpointRequest[]; - }; + const transformsInfo = req.body as TransformEndpointRequest[]; try { return res.ok({ @@ -313,9 +311,7 @@ async function startTransforms( } const stopTransformsHandler: RequestHandler = async (ctx, req, res) => { - const { transformsInfo } = req.body as { - transformsInfo: TransformEndpointRequest[]; - }; + const transformsInfo = req.body as TransformEndpointRequest[]; try { return res.ok({ diff --git a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts index 422fdec7ab77e0..722a3f52376b4b 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditMessage } from '../../../../../legacy/plugins/transform/common/types/messages'; +import { AuditMessage } from '../../../common/types/messages'; import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; import { RouteDependencies } from '../../types'; diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts index 953490920cbcb1..07c21e58e64e44 100644 --- a/x-pack/plugins/transform/server/routes/index.ts +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -9,7 +9,7 @@ import { RouteDependencies } from '../types'; import { registerPrivilegesRoute } from './api/privileges'; import { registerTransformsRoutes } from './api/transforms'; -import { API_BASE_PATH } from '../../../../legacy/plugins/transform/common/constants'; +import { API_BASE_PATH } from '../../common/constants'; export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 195b75f84a8c0c..568108aff75031 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7257,9 +7257,6 @@ "xpack.maps.source.esGrid.showasFieldLabel": "表示形式", "xpack.maps.source.esGridDescription": "それぞれのグリッド付きセルのメトリックでグリッドにグループ分けされた地理空間データです。", "xpack.maps.source.esGridTitle": "グリッド集約", - "xpack.maps.source.esJoin.joinDescription": "{description} の Elasticsearch 用語集約リクエストです", - "xpack.maps.source.esJoin.joinLeftDescription": "{leftSourceName}:{leftFieldName} を次と結合:", - "xpack.maps.source.esJoin.joinMetricsDescription": "メトリック {metrics} の", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "検索への応答を geoJson 機能コレクションに変換できません。エラー: {errorMsg}", "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "インデックス「{indexPatternTitle}」はドキュメント数が少なく、ダイナミックフィルターが必要ありません。", "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です", @@ -10154,7 +10151,6 @@ "xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage": "ドキュメントストリームが生成されていません。", "xpack.reporting.exportTypes.printablePdf.logoDescription": "Elastic 提供", "xpack.reporting.exportTypes.printablePdf.pagingDescription": "{pageCount} ページ中 {currentPage} ページ目", - "xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "ページで予期せぬメッセージが発生しました: {toastHeaderText}", "xpack.reporting.jobStatuses.cancelledText": "キャンセル済み", "xpack.reporting.jobStatuses.completedText": "完了", "xpack.reporting.jobStatuses.failedText": "失敗", @@ -10172,9 +10168,6 @@ "xpack.reporting.listing.tableColumns.createdAtTitle": "作成日時:", "xpack.reporting.listing.tableColumns.reportTitle": "レポート", "xpack.reporting.listing.tableColumns.statusTitle": "ステータス", - "xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText": " - 最大サイズに達成", - "xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText": "保留中 - ジョブの処理持ち", - "xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText": "{statusTimestamp} 時点で {statusLabel}", "xpack.reporting.management.reportingTitle": "レポート", "xpack.reporting.panelContent.copyUrlButtonLabel": "POST URL をコピー", "xpack.reporting.panelContent.generateButtonLabel": "{reportingType} を生成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9add5c6bcdbc32..a91f55960e34fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7257,9 +7257,6 @@ "xpack.maps.source.esGrid.showasFieldLabel": "显示为", "xpack.maps.source.esGridDescription": "地理空间数据在网格中进行分组,每个网格单元格都具有指标", "xpack.maps.source.esGridTitle": "网格聚合", - "xpack.maps.source.esJoin.joinDescription": "{description} 的 Elasticsearch 词聚合请求", - "xpack.maps.source.esJoin.joinLeftDescription": "将 {leftSourceName}:{leftFieldName} 联接到", - "xpack.maps.source.esJoin.joinMetricsDescription": "以获取指标 {metrics}", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "无法将搜索响应转换成 geoJson 功能集合,错误:{errorMsg}", "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "索引“{indexPatternTitle}”具有很少数量的文档,不需要动态筛选。", "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "动态数据筛选已禁用", @@ -10154,7 +10151,6 @@ "xpack.reporting.exportTypes.printablePdf.documentStreamIsNotgeneratedErrorMessage": "尚未生成文档流", "xpack.reporting.exportTypes.printablePdf.logoDescription": "由 Elastic 提供支持", "xpack.reporting.exportTypes.printablePdf.pagingDescription": "第 {currentPage} 页,共 {pageCount} 页", - "xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "在页面上出现意外消息:{toastHeaderText}", "xpack.reporting.jobStatuses.cancelledText": "已取消", "xpack.reporting.jobStatuses.completedText": "已完成", "xpack.reporting.jobStatuses.failedText": "失败", @@ -10172,9 +10168,6 @@ "xpack.reporting.listing.tableColumns.createdAtTitle": "创建于", "xpack.reporting.listing.tableColumns.reportTitle": "报告", "xpack.reporting.listing.tableColumns.statusTitle": "状态", - "xpack.reporting.listing.tableValue.createdAtDetail.maxSizeReachedText": " - 最大大小已达到", - "xpack.reporting.listing.tableValue.createdAtDetail.pendingStatusReachedText": "待处理 - 正在等候处理作业", - "xpack.reporting.listing.tableValue.createdAtDetail.statusTimestampText": "{statusTimestamp} 时为 {statusLabel}", "xpack.reporting.management.reportingTitle": "报告", "xpack.reporting.panelContent.copyUrlButtonLabel": "复制 POST URL", "xpack.reporting.panelContent.generateButtonLabel": "生成 {reportingType}", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index c6a7808356b867..0d667f477f9367 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -43,6 +43,9 @@ Table of Contents - [Action type model definition](#action-type-model-definition) - [Register action type model](#register-action-type-model) - [Create and register new action type UI example](#reate-and-register-new-action-type-ui-example) + - [Embed the Alert Actions form within any Kibana plugin](#embed-the-alert-actions-form-within-any-kibana-plugin) + - [Embed the Create Connector flyout within any Kibana plugin](#embed-the-create-connector-flyout-within-any-kibana-plugin) + - [Embed the Edit Connector flyout within any Kibana plugin](#embed-the-edit-connector-flyout-within-any-kibana-plugin) ## Built-in Alert Types @@ -69,7 +72,7 @@ AlertTypeModel: ``` export function getAlertType(): AlertTypeModel { return { - id: 'threshold', + id: '.index-threshold', name: 'Index Threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, @@ -658,8 +661,6 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false); // in render section of component <AlertsContextProvider value={{ - addFlyoutVisible: alertFlyoutVisible, - setAddFlyoutVisibility: setAlertFlyoutVisibility, http, actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, @@ -667,9 +668,11 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false); uiSettings, charts, dataFieldsFormats, + metadata: { test: 'some value', fields: ['test'] }, }} > - <AlertAdd consumer={'watcher'} /> + <AlertAdd consumer={'watcher'} addFlyoutVisible={alertFlyoutVisible} + setAddFlyoutVisibility={setAlertFlyoutVisibility} /> </AlertsContextProvider> ``` @@ -677,6 +680,8 @@ AlertAdd Props definition: ``` interface AlertAddProps { consumer: string; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; alertTypeId?: string; canChangeTrigger?: boolean; } @@ -685,40 +690,40 @@ interface AlertAddProps { |Property|Description| |---|---| |consumer|Name of the plugin that creates an alert.| +|addFlyoutVisible|Visibility state of the Create Alert flyout.| +|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |alertTypeId|Optional property to preselect alert type.| |canChangeTrigger|Optional property, that hides change alert type possibility.| AlertsContextProvider value options: ``` -export interface AlertsContextValue { - addFlyoutVisible: boolean; - setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; +export interface AlertsContextValue<MetaData = Record<string, any>> { reloadAlerts?: () => Promise<void>; http: HttpSetup; alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; charts?: ChartsPluginSetup; dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>; + metadata?: MetaData; } ``` |Property|Description| |---|---| -|addFlyoutVisible|Visibility state of the Create Alert flyout.| -|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| |http|HttpSetup needed for executing API calls.| |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| -|toastNotifications|Optional toast messages.| +|toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|metadata|Optional generic property, which allows to define component specific metadata. This metadata can be used for passing down preloaded data for Alert type expression component.| ## Build and register Action Types @@ -1198,3 +1203,358 @@ Clicking on the select card for `Example Action Type` will open the action type or create a new connector: ![Example Action Type with empty connectors list](https://i.imgur.com/EamA9Xv.png) + +## Embed the Alert Actions form within any Kibana plugin + +Follow the instructions bellow to embed the Alert Actions form within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependencies will be used to embed Actions form or register your own action type. + +2. Add Actions form to React component: + +``` + import React, { useCallback } from 'react'; + import { ActionForm } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + import { AlertAction } from '../../../../../../../../../plugins/triggers_actions_ui/public/types'; + + const ALOWED_BY_PLUGIN_ACTION_TYPES = [ + { id: '.email', name: 'Email', enabled: true }, + { id: '.index', name: 'Index', enabled: false }, + { id: '.example-action', name: 'Example Action', enabled: false }, + ]; + + export const ComponentWithActionsForm: () => { + const { http, triggers_actions_ui, toastNotifications } = useKibana().services; + const actionTypeRegistry = triggers_actions_ui.actionTypeRegistry; + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: '.index-threshold', + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: '.index', + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + return ( + <ActionForm + actions={initialAlert.actions} + messageVariables={['test var1', 'test var2']} + defaultActionGroupId={'default'} + setActionIdByIndex={(id: string, index: number) => { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={ALOWED_BY_PLUGIN_ACTION_TYPES} + toastNotifications={toastNotifications} + /> + ); + }; +``` + +ActionForm Props definition: +``` +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +``` + +|Property|Description| +|---|---| +|actions|List of actions comes from alert.actions property.| +|defaultActionGroupId|Default action group id to which each new action will belong to.| +|setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| +|setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|toastNotifications|Toast messages.| +|actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| +|actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| +|defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| + + +AlertsContextProvider value options: +``` +export interface AlertsContextValue { + reloadAlerts?: () => Promise<void>; + http: HttpSetup; + alertTypeRegistry: TypeRegistry<AlertTypeModel>; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + uiSettings?: IUiSettingsClient; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + charts?: ChartsPluginSetup; + dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>; +} +``` + +|Property|Description| +|---|---| +|reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| +|http|HttpSetup needed for executing API calls.| +|alertTypeRegistry|Registry for alert types.| +|actionTypeRegistry|Registry for action types.| +|uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|toastNotifications|Toast messages.| +|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| + +## Embed the Create Connector flyout within any Kibana plugin + +Follow the instructions bellow to embed the Create Connector flyout within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependency will be used to embed Create Connector flyout or register new action type. + +2. Add Create Connector flyout to React component: +``` +// import section +import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../../../../../triggers_actions_ui/public'; + +// in the component state definition section +const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); + +// load required dependancied +const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; + +const connector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + actionType: 'Index', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + +// UI control item for open flyout +<EuiButton + fill + iconType="plusInCircle" + iconSide="left" + onClick={() => setAddFlyoutVisibility(true)} +> + <FormattedMessage + id="emptyButton" + defaultMessage="Create connector" + /> +</EuiButton> + +// in render section of component +<ActionsConnectorsContextProvider + value={{ + http: http, + toastNotifications: toastNotifications, + actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, + capabilities: capabilities, + }} + > + <ConnectorAddFlyout + addFlyoutVisible={addFlyoutVisible} + setAddFlyoutVisibility={setAddFlyoutVisibility} + actionTypes={[ + { + id: '.index', + enabled: true, + name: 'Index', + }, + ]} + /> +</ActionsConnectorsContextProvider> +``` + +ConnectorAddFlyout Props definition: +``` +export interface ConnectorAddFlyoutProps { + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; + actionTypes?: ActionType[]; +} +``` + +|Property|Description| +|---|---| +|addFlyoutVisible|Visibility state of the Create Connector flyout.| +|setAddFlyoutVisibility|Function for changing visibility state of the Create Connector flyout.| +|actionTypes|Optional property, that allows to define only specific action types list which is available for a current plugin.| + +ActionsConnectorsContextValue options: +``` +export interface ActionsConnectorsContextValue { + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; +} +``` + +|Property|Description| +|---|---| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.| +|toastNotifications|Toast messages.| +|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.| + + +## Embed the Edit Connector flyout within any Kibana plugin + +Follow the instructions bellow to embed the Edit Connector flyout within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependency will be used to embed Edit Connector flyout. + +2. Add Create Connector flyout to React component: +``` +// import section +import { ActionsConnectorsContextProvider, ConnectorEditFlyout } from '../../../../../../../triggers_actions_ui/public'; + +// in the component state definition section +const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false); + +// load required dependancied +const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; + +// UI control item for open flyout +<EuiButton + fill + iconType="plusInCircle" + iconSide="left" + onClick={() => setEditFlyoutVisibility(true)} +> + <FormattedMessage + id="emptyButton" + defaultMessage="Edit connector" + /> +</EuiButton> + +// in render section of component +<ActionsConnectorsContextProvider + value={{ + http: http, + toastNotifications: toastNotifications, + actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, + capabilities: capabilities, + }} + > + <ConnectorEditFlyout + initialConnector={connector} + editFlyoutVisible={editFlyoutVisible} + setEditFlyoutVisibility={setEditFlyoutVisibility} + /> +</ActionsConnectorsContextProvider> + +``` + +ConnectorEditFlyout Props definition: +``` +export interface ConnectorEditProps { + initialConnector: ActionConnectorTableItem; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; +} +``` + +|Property|Description| +|---|---| +|initialConnector|Property, that allows to define the initial state of edited connector.| +|editFlyoutVisible|Visibility state of the Edit Connector flyout.| +|setEditFlyoutVisibility|Function for changing visibility state of the Edit Connector flyout.| + +ActionsConnectorsContextValue options: +``` +export interface ActionsConnectorsContextValue { + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; +} +``` + +|Property|Description| +|---|---| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.| +|toastNotifications|Toast messages.| +|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.| + diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index cf66883412edb3..6883faa5ee2302 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, + "optionalPlugins": ["alerting", "alertingBuiltins"], "requiredPlugins": ["management", "charts", "data"] } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index f82b2c8c88adaf..6c994051ec9808 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -263,14 +263,14 @@ const EmailActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsP isInvalid={errors.port.length > 0 && port !== undefined} fullWidth name="port" - value={port} + value={port || ''} data-test-subj="emailPortInput" onChange={e => { editActionConfig('port', parseInt(e.target.value, 10)); }} onBlur={() => { if (!port) { - editActionConfig('port', ''); + editActionConfig('port', 0); } }} /> @@ -380,7 +380,7 @@ const EmailParamsFields: React.FunctionComponent<ActionParamsProps<EmailActionPa const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx index 8d8045042cfc31..f0ac43c04ee0e4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx @@ -75,7 +75,7 @@ export const ServerLogParamsFields: React.FunctionComponent<ActionParamsProps< useEffect(() => { editAction('level', 'info', index); - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index 916715de7ae185..a8ba11faa08ddb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -143,7 +143,7 @@ const SlackParamsFields: React.FunctionComponent<ActionParamsProps<SlackActionPa const { message } = actionParams; const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.scss b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.scss new file mode 100644 index 00000000000000..d0a7039ae24e17 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.scss @@ -0,0 +1,3 @@ +.actAlertVisualization__chart { + height: $euiSize * 15; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index 9a01a7f50c3df9..2bf779e5506187 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -20,6 +20,8 @@ import { EuiComboBoxOptionOption, EuiFormRow, EuiCallOut, + EuiEmptyPrompt, + EuiText, } from '@elastic/eui'; import { COMPARATORS, builtInComparators } from '../../../../common/constants'; import { @@ -39,6 +41,7 @@ import { import { builtInAggregationTypes } from '../../../../common/constants'; import { IndexThresholdAlertParams } from './types'; import { AlertsContextValue } from '../../../context/alerts_context'; +import './expression.scss'; const DEFAULT_VALUES = { AGGREGATION_TYPE: 'count', @@ -63,6 +66,7 @@ const expressionFieldsWithValidation = [ interface IndexThresholdProps { alertParams: IndexThresholdAlertParams; + alertInterval: string; setAlertParams: (property: string, value: any) => void; setAlertProperty: (key: string, value: any) => void; errors: { [key: string]: string[] }; @@ -71,6 +75,7 @@ interface IndexThresholdProps { export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThresholdProps> = ({ alertParams, + alertInterval, setAlertParams, setAlertProperty, errors, @@ -451,6 +456,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR} threshold={threshold} errors={errors} + popupPosition={'upLeft'} onChangeSelectedThreshold={selectedThresholds => setAlertParams('threshold', selectedThresholds) } @@ -461,6 +467,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr </EuiFlexItem> <EuiFlexItem grow={false}> <ForLastExpression + popupPosition={'upLeft'} timeWindowSize={timeWindowSize || 1} timeWindowUnit={timeWindowUnit || ''} errors={errors} @@ -473,16 +480,35 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr /> </EuiFlexItem> </EuiFlexGroup> - {canShowVizualization ? null : ( - <Fragment> - <ThresholdVisualization - alertParams={alertParams} - aggregationTypes={builtInAggregationTypes} - comparators={builtInComparators} - alertsContext={alertsContext} - /> - </Fragment> - )} + <EuiSpacer size="l" /> + <div className="actAlertVisualization__chart"> + {canShowVizualization ? ( + <Fragment> + <EuiSpacer size="xl" /> + <EuiEmptyPrompt + iconType="visBarVertical" + body={ + <EuiText color="subdued"> + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertAdd.previewAlertVisualizationDescription" + defaultMessage="Complete the expression above to generate a preview" + /> + </EuiText> + } + /> + </Fragment> + ) : ( + <Fragment> + <ThresholdVisualization + alertParams={alertParams} + alertInterval={alertInterval} + aggregationTypes={builtInAggregationTypes} + comparators={builtInComparators} + alertsContext={alertsContext} + /> + </Fragment> + )} + </div> </Fragment> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/api.ts index 956007049a8216..943e0e5d7b8359 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/api.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { HttpSetup } from 'kibana/public'; +import { TimeSeriesResult } from '../types'; +export { TimeSeriesResult } from '../types'; const WATCHER_API_ROOT = '/api/watcher'; @@ -60,20 +62,35 @@ export const loadIndexPatterns = async () => { return savedObjects; }; +const TimeSeriesQueryRoute = '/api/alerting_builtins/index_threshold/_time_series_query'; + +interface GetThresholdAlertVisualizationDataParams { + model: any; + visualizeOptions: any; + http: HttpSetup; +} + export async function getThresholdAlertVisualizationData({ model, visualizeOptions, http, -}: { - model: any; - visualizeOptions: any; - http: HttpSetup; -}): Promise<Record<string, any>> { - const { visualizeData } = await http.post(`${WATCHER_API_ROOT}/watch/visualize`, { - body: JSON.stringify({ - watch: model, - options: visualizeOptions, - }), +}: GetThresholdAlertVisualizationDataParams): Promise<TimeSeriesResult> { + const timeSeriesQueryParams = { + index: model.index, + timeField: model.timeField, + aggType: model.aggType, + aggField: model.aggField, + groupBy: model.groupBy, + termField: model.termField, + termSize: model.termSize, + timeWindowSize: model.timeWindowSize, + timeWindowUnit: model.timeWindowUnit, + dateStart: new Date(visualizeOptions.rangeFrom).toISOString(), + dateEnd: new Date(visualizeOptions.rangeTo).toISOString(), + interval: visualizeOptions.interval, + }; + + return await http.post<TimeSeriesResult>(TimeSeriesQueryRoute, { + body: JSON.stringify(timeSeriesQueryParams), }); - return visualizeData; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.test.ts deleted file mode 100644 index 34e435be152f68..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment'; - -import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; - -describe('calcAutoIntervalNear', () => { - test('1h/0 buckets = 0ms buckets', () => { - const interval = calcAutoIntervalNear(0, Number(moment.duration(1, 'h'))); - expect(interval.asMilliseconds()).toBe(0); - }); - - test('undefined/100 buckets = 0ms buckets', () => { - const interval = calcAutoIntervalNear(0, undefined as any); - expect(interval.asMilliseconds()).toBe(0); - }); - - test('1ms/100 buckets = 1ms buckets', () => { - const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'ms'))); - expect(interval.asMilliseconds()).toBe(1); - }); - - test('200ms/100 buckets = 2ms buckets', () => { - const interval = calcAutoIntervalNear(100, Number(moment.duration(200, 'ms'))); - expect(interval.asMilliseconds()).toBe(2); - }); - - test('1s/1000 buckets = 1ms buckets', () => { - const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 's'))); - expect(interval.asMilliseconds()).toBe(1); - }); - - test('1000h/1000 buckets = 1h buckets', () => { - const interval = calcAutoIntervalNear(1000, Number(moment.duration(1000, 'hours'))); - expect(interval.asHours()).toBe(1); - }); - - test('1h/100 buckets = 30s buckets', () => { - const interval = calcAutoIntervalNear(100, Number(moment.duration(1, 'hours'))); - expect(interval.asSeconds()).toBe(30); - }); - - test('1d/25 buckets = 1h buckets', () => { - const interval = calcAutoIntervalNear(25, Number(moment.duration(1, 'day'))); - expect(interval.asHours()).toBe(1); - }); - - test('1y/1000 buckets = 12h buckets', () => { - const interval = calcAutoIntervalNear(1000, Number(moment.duration(1, 'year'))); - expect(interval.asHours()).toBe(12); - }); - - test('1y/10000 buckets = 1h buckets', () => { - const interval = calcAutoIntervalNear(10000, Number(moment.duration(1, 'year'))); - expect(interval.asHours()).toBe(1); - }); - - test('1y/100000 buckets = 5m buckets', () => { - const interval = calcAutoIntervalNear(100000, Number(moment.duration(1, 'year'))); - expect(interval.asMinutes()).toBe(5); - }); -}); - -describe('calcAutoIntervalLessThan', () => { - test('1h/0 buckets = 0ms buckets', () => { - const interval = calcAutoIntervalLessThan(0, Number(moment.duration(1, 'h'))); - expect(interval.asMilliseconds()).toBe(0); - }); - - test('undefined/100 buckets = 0ms buckets', () => { - const interval = calcAutoIntervalLessThan(0, undefined as any); - expect(interval.asMilliseconds()).toBe(0); - }); - - test('1ms/100 buckets = 1ms buckets', () => { - const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'ms'))); - expect(interval.asMilliseconds()).toBe(1); - }); - - test('200ms/100 buckets = 2ms buckets', () => { - const interval = calcAutoIntervalLessThan(100, Number(moment.duration(200, 'ms'))); - expect(interval.asMilliseconds()).toBe(2); - }); - - test('1s/1000 buckets = 1ms buckets', () => { - const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 's'))); - expect(interval.asMilliseconds()).toBe(1); - }); - - test('1000h/1000 buckets = 1h buckets', () => { - const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1000, 'hours'))); - expect(interval.asHours()).toBe(1); - }); - - test('1h/100 buckets = 30s buckets', () => { - const interval = calcAutoIntervalLessThan(100, Number(moment.duration(1, 'hours'))); - expect(interval.asSeconds()).toBe(30); - }); - - test('1d/25 buckets = 30m buckets', () => { - const interval = calcAutoIntervalLessThan(25, Number(moment.duration(1, 'day'))); - expect(interval.asMinutes()).toBe(30); - }); - - test('1y/1000 buckets = 3h buckets', () => { - const interval = calcAutoIntervalLessThan(1000, Number(moment.duration(1, 'year'))); - expect(interval.asHours()).toBe(3); - }); - - test('1y/10000 buckets = 30m buckets', () => { - const interval = calcAutoIntervalLessThan(10000, Number(moment.duration(1, 'year'))); - expect(interval.asMinutes()).toBe(30); - }); - - test('1y/100000 buckets = 5m buckets', () => { - const interval = calcAutoIntervalLessThan(100000, Number(moment.duration(1, 'year'))); - expect(interval.asMinutes()).toBe(5); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.ts deleted file mode 100644 index c910f1e6752d4a..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_auto_interval.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment'; - -const boundsDescending = [ - { - bound: Infinity, - interval: Number(moment.duration(1, 'year')), - }, - { - bound: Number(moment.duration(1, 'year')), - interval: Number(moment.duration(1, 'month')), - }, - { - bound: Number(moment.duration(3, 'week')), - interval: Number(moment.duration(1, 'week')), - }, - { - bound: Number(moment.duration(1, 'week')), - interval: Number(moment.duration(1, 'd')), - }, - { - bound: Number(moment.duration(24, 'hour')), - interval: Number(moment.duration(12, 'hour')), - }, - { - bound: Number(moment.duration(6, 'hour')), - interval: Number(moment.duration(3, 'hour')), - }, - { - bound: Number(moment.duration(2, 'hour')), - interval: Number(moment.duration(1, 'hour')), - }, - { - bound: Number(moment.duration(45, 'minute')), - interval: Number(moment.duration(30, 'minute')), - }, - { - bound: Number(moment.duration(20, 'minute')), - interval: Number(moment.duration(10, 'minute')), - }, - { - bound: Number(moment.duration(9, 'minute')), - interval: Number(moment.duration(5, 'minute')), - }, - { - bound: Number(moment.duration(3, 'minute')), - interval: Number(moment.duration(1, 'minute')), - }, - { - bound: Number(moment.duration(45, 'second')), - interval: Number(moment.duration(30, 'second')), - }, - { - bound: Number(moment.duration(15, 'second')), - interval: Number(moment.duration(10, 'second')), - }, - { - bound: Number(moment.duration(7.5, 'second')), - interval: Number(moment.duration(5, 'second')), - }, - { - bound: Number(moment.duration(5, 'second')), - interval: Number(moment.duration(1, 'second')), - }, - { - bound: Number(moment.duration(500, 'ms')), - interval: Number(moment.duration(100, 'ms')), - }, -]; - -function getPerBucketMs(count: number, duration: number) { - const ms = duration / count; - return isFinite(ms) ? ms : NaN; -} - -function normalizeMinimumInterval(targetMs: number) { - const value = isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1); - return moment.duration(value); -} - -/** - * Using some simple rules we pick a "pretty" interval that will - * produce around the number of buckets desired given a time range. - * - * @param targetBucketCount desired number of buckets - * @param duration time range the agg covers - */ -export function calcAutoIntervalNear(targetBucketCount: number, duration: number) { - const targetPerBucketMs = getPerBucketMs(targetBucketCount, duration); - - // Find the first bound which is smaller than our target. - const lowerBoundIndex = boundsDescending.findIndex(({ bound }) => { - const boundMs = Number(bound); - return boundMs <= targetPerBucketMs; - }); - - // The bound immediately preceeding that lower bound contains the - // interval most closely matching our target. - if (lowerBoundIndex !== -1) { - const nearestInterval = boundsDescending[lowerBoundIndex - 1].interval; - return moment.duration(nearestInterval); - } - - // If the target is smaller than any of our bounds, then we'll use it for the interval as-is. - return normalizeMinimumInterval(targetPerBucketMs); -} - -/** - * Pick a "pretty" interval that produces no more than the maxBucketCount - * for the given time range. - * - * @param maxBucketCount maximum number of buckets to create - * @param duration amount of time covered by the agg - */ -export function calcAutoIntervalLessThan(maxBucketCount: number, duration: number) { - const maxPerBucketMs = getPerBucketMs(maxBucketCount, duration); - - for (const { interval } of boundsDescending) { - // Find the highest interval which meets our per bucket limitation. - if (interval <= maxPerBucketMs) { - return moment.duration(interval); - } - } - - // If the max is smaller than any of our intervals, then we'll use it for the interval as-is. - return normalizeMinimumInterval(maxPerBucketMs); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_es_interval.js b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_es_interval.js deleted file mode 100644 index bb5725c567b1f3..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/calc_es_interval.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import dateMath from '@elastic/datemath'; - -import { parseEsInterval } from '../../../../../../../../../../src/plugins/data/public'; - -const unitsDesc = dateMath.unitsDesc; -const largeMax = unitsDesc.indexOf('M'); - -/** - * Convert a moment.duration into an es - * compatible expression, and provide - * associated metadata - * - * @param {moment.duration} duration - * @return {object} - */ -export function convertDurationToNormalizedEsInterval(duration) { - for (let i = 0; i < unitsDesc.length; i++) { - const unit = unitsDesc[i]; - const val = duration.as(unit); - // find a unit that rounds neatly - if (val >= 1 && Math.floor(val) === val) { - // if the unit is "large", like years, but - // isn't set to 1 ES will puke. So keep going until - // we get out of the "large" units - if (i <= largeMax && val !== 1) { - continue; - } - - return { - value: val, - unit: unit, - expression: val + unit, - }; - } - } - - const ms = duration.as('ms'); - return { - value: ms, - unit: 'ms', - expression: ms + 'ms', - }; -} - -export function convertIntervalToEsInterval(interval) { - const { value, unit } = parseEsInterval(interval); - return { - value, - unit, - expression: interval, - }; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/time_buckets.js b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/time_buckets.js deleted file mode 100644 index f49e85ddefea85..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/lib/time_buckets/time_buckets.js +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import moment from 'moment'; -import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; -import { - convertDurationToNormalizedEsInterval, - convertIntervalToEsInterval, -} from './calc_es_interval'; -import { fieldFormats, parseInterval } from '../../../../../../../../../../src/plugins/data/public'; - -function isValidMoment(m) { - return m && 'isValid' in m && m.isValid(); -} - -/** - * Helper class for wrapping the concept of an "Interval", - * which describes a timespan that will separate moments. - * - * @param {state} object - one of "" - * @param {[type]} display [description] - */ -function TimeBuckets(uiSettings, dataFieldsFormats) { - this.uiSettings = uiSettings; - this.dataFieldsFormats = dataFieldsFormats; - return TimeBuckets.__cached__(this); -} - -/**** - * PUBLIC API - ****/ - -/** - * Set the bounds that these buckets are expected to cover. - * This is required to support interval "auto" as well - * as interval scaling. - * - * @param {object} input - an object with properties min and max, - * representing the edges for the time span - * we should cover - * - * @returns {undefined} - */ -TimeBuckets.prototype.setBounds = function(input) { - if (!input) return this.clearBounds(); - - let bounds; - if (_.isPlainObject(input)) { - // accept the response from timefilter.getActiveBounds() - bounds = [input.min, input.max]; - } else { - bounds = Array.isArray(input) ? input : []; - } - - const moments = _(bounds) - .map(_.ary(moment, 1)) - .sortBy(Number); - - const valid = moments.size() === 2 && moments.every(isValidMoment); - if (!valid) { - this.clearBounds(); - throw new Error('invalid bounds set: ' + input); - } - - this._lb = moments.shift(); - this._ub = moments.pop(); - if (this.getDuration().asSeconds() < 0) { - throw new TypeError('Intervals must be positive'); - } -}; - -/** - * Clear the stored bounds - * - * @return {undefined} - */ -TimeBuckets.prototype.clearBounds = function() { - this._lb = this._ub = null; -}; - -/** - * Check to see if we have received bounds yet - * - * @return {Boolean} - */ -TimeBuckets.prototype.hasBounds = function() { - return isValidMoment(this._ub) && isValidMoment(this._lb); -}; - -/** - * Return the current bounds, if we have any. - * - * THIS DOES NOT CLONE THE BOUNDS, so editing them - * may have unexpected side-effects. Always - * call bounds.min.clone() before editing - * - * @return {object|undefined} - If bounds are not defined, this - * returns undefined, else it returns the bounds - * for these buckets. This object has two props, - * min and max. Each property will be a moment() - * object - * - */ -TimeBuckets.prototype.getBounds = function() { - if (!this.hasBounds()) return; - return { - min: this._lb, - max: this._ub, - }; -}; - -/** - * Get a moment duration object representing - * the distance between the bounds, if the bounds - * are set. - * - * @return {moment.duration|undefined} - */ -TimeBuckets.prototype.getDuration = function() { - if (!this.hasBounds()) return; - return moment.duration(this._ub - this._lb, 'ms'); -}; - -/** - * Update the interval at which buckets should be - * generated. - * - * Input can be one of the following: - * - Any object from src/legacy/ui/agg_types/buckets/_interval_options.js - * - "auto" - * - Pass a valid moment unit - * - a moment.duration object. - * - * @param {object|string|moment.duration} input - see desc - */ -TimeBuckets.prototype.setInterval = function(input) { - // Preserve the original units because they're lost when the interval is converted to a - // moment duration object. - this.originalInterval = input; - - let interval = input; - - // selection object -> val - if (_.isObject(input)) { - interval = input.val; - } - - if (!interval || interval === 'auto') { - this._i = 'auto'; - return; - } - - if (_.isString(interval)) { - input = interval; - interval = parseInterval(interval); - if (+interval === 0) { - interval = null; - } - } - - // if the value wasn't converted to a duration, and isn't - // already a duration, we have a problem - if (!moment.isDuration(interval)) { - throw new TypeError('"' + input + '" is not a valid interval.'); - } - - this._i = interval; -}; - -/** - * Get the interval for the buckets. If the - * number of buckets created by the interval set - * is larger than config:histogram:maxBars then the - * interval will be scaled up. If the number of buckets - * created is less than one, the interval is scaled back. - * - * The interval object returned is a moment.duration - * object that has been decorated with the following - * properties. - * - * interval.description: a text description of the interval. - * designed to be used list "field per {{ desc }}". - * - "minute" - * - "10 days" - * - "3 years" - * - * interval.expr: the elasticsearch expression that creates this - * interval. If the interval does not properly form an elasticsearch - * expression it will be forced into one. - * - * interval.scaled: the interval was adjusted to - * accommodate the maxBars setting. - * - * interval.scale: the number that y-values should be - * multiplied by - * - * interval.scaleDescription: a description that reflects - * the values which will be produced by using the - * interval.scale. - * - * - * @return {[type]} [description] - */ -TimeBuckets.prototype.getInterval = function(useNormalizedEsInterval = true) { - const self = this; - const duration = self.getDuration(); - const parsedInterval = readInterval(); - - if (useNormalizedEsInterval) { - return decorateInterval(maybeScaleInterval(parsedInterval)); - } else { - return decorateInterval(parsedInterval); - } - - // either pull the interval from state or calculate the auto-interval - function readInterval() { - const interval = self._i; - if (moment.isDuration(interval)) return interval; - return calcAutoIntervalNear(self.uiSettings.get('histogram:barTarget'), Number(duration)); - } - - // check to see if the interval should be scaled, and scale it if so - function maybeScaleInterval(interval) { - if (!self.hasBounds()) return interval; - - const maxLength = self.uiSettings.get('histogram:maxBars'); - const approxLen = duration / interval; - let scaled; - - if (approxLen > maxLength) { - scaled = calcAutoIntervalLessThan(maxLength, Number(duration)); - } else { - return interval; - } - - if (+scaled === +interval) return interval; - - decorateInterval(interval); - return _.assign(scaled, { - preScaled: interval, - scale: interval / scaled, - scaled: true, - }); - } - - // append some TimeBuckets specific props to the interval - function decorateInterval(interval) { - const esInterval = useNormalizedEsInterval - ? convertDurationToNormalizedEsInterval(interval) - : convertIntervalToEsInterval(self.originalInterval); - interval.esValue = esInterval.value; - interval.esUnit = esInterval.unit; - interval.expression = esInterval.expression; - interval.overflow = duration > interval ? moment.duration(interval - duration) : false; - - const prettyUnits = moment.normalizeUnits(esInterval.unit); - if (esInterval.value === 1) { - interval.description = prettyUnits; - } else { - interval.description = esInterval.value + ' ' + prettyUnits + 's'; - } - - return interval; - } -}; - -/** - * Get a date format string that will represent dates that - * progress at our interval. - * - * Since our interval can be as small as 1ms, the default - * date format is usually way too much. with `dateFormat:scaled` - * users can modify how dates are formatted within series - * produced by TimeBuckets - * - * @return {string} - */ -TimeBuckets.prototype.getScaledDateFormat = function() { - const interval = this.getInterval(); - const rules = this.uiSettings.get('dateFormat:scaled'); - - for (let i = rules.length - 1; i >= 0; i--) { - const rule = rules[i]; - if (!rule[0] || interval >= moment.duration(rule[0])) { - return rule[1]; - } - } - - return this.uiSettings.get('dateFormat'); -}; - -TimeBuckets.prototype.getScaledDateFormatter = function() { - const fieldFormatsService = this.dataFieldsFormats; - const DateFieldFormat = fieldFormatsService.getType(fieldFormats.FIELD_FORMAT_IDS.DATE); - - return new DateFieldFormat( - { - pattern: this.getScaledDateFormat(), - }, - configPath => this.uiSettings.get(configPath) - ); -}; - -TimeBuckets.__cached__ = function(self) { - let cache = {}; - const sameMoment = same(moment.isMoment); - const sameDuration = same(moment.isDuration); - - const desc = { - __cached__: { - value: self, - }, - }; - - const breakers = { - setBounds: 'bounds', - clearBounds: 'bounds', - setInterval: 'interval', - }; - - const resources = { - bounds: { - setup: function() { - return [self._lb, self._ub]; - }, - changes: function(prev) { - return !sameMoment(prev[0], self._lb) || !sameMoment(prev[1], self._ub); - }, - }, - interval: { - setup: function() { - return self._i; - }, - changes: function(prev) { - return !sameDuration(prev, this._i); - }, - }, - }; - - function cachedGetter(prop) { - return { - value: function cachedGetter(...rest) { - if (cache.hasOwnProperty(prop)) { - return cache[prop]; - } - - return (cache[prop] = self[prop](...rest)); - }, - }; - } - - function cacheBreaker(prop) { - const resource = resources[breakers[prop]]; - const setup = resource.setup; - const changes = resource.changes; - const fn = self[prop]; - - return { - value: function cacheBreaker() { - const prev = setup.call(self); - const ret = fn.apply(self, arguments); - - if (changes.call(self, prev)) { - cache = {}; - } - - return ret; - }, - }; - } - - function same(checkType) { - return function(a, b) { - if (a === b) return true; - if (checkType(a) === checkType(b)) return +a === +b; - return false; - }; - } - - _.forOwn(TimeBuckets.prototype, function(fn, prop) { - if (prop[0] === '_') return; - - if (breakers.hasOwnProperty(prop)) { - desc[prop] = cacheBreaker(prop); - } else { - desc[prop] = cachedGetter(prop); - } - }); - - return Object.create(self, desc); -}; - -export { TimeBuckets }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/types.ts index 356b0fbbc0845a..d5b64f1489b8db 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/types.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + TimeSeriesResult, + TimeSeriesResultRow, + MetricResult, +} from '../../../../../../alerting_builtins/common/alert_types/index_threshold'; + export interface Comparator { text: string; value: string; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx index 4d97a59e36320e..f27e35fe7609d1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -5,7 +5,7 @@ */ import React, { Fragment, useEffect, useState } from 'react'; -import { IUiSettingsClient } from 'kibana/public'; +import { IUiSettingsClient, HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { AnnotationDomainTypes, @@ -18,17 +18,16 @@ import { Position, ScaleType, Settings, + niceTimeFormatter, } from '@elastic/charts'; -import dateMath from '@elastic/datemath'; import moment from 'moment-timezone'; import { EuiCallOut, EuiLoadingChart, EuiSpacer, EuiEmptyPrompt, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { getThresholdAlertVisualizationData } from './lib/api'; import { AggregationType, Comparator } from '../../../../common/types'; -/* TODO: This file was copied from ui/time_buckets for NP migration. We should clean this up and add TS support */ -import { TimeBuckets } from './lib/time_buckets'; import { AlertsContextValue } from '../../../context/alerts_context'; import { IndexThresholdAlertParams } from './types'; +import { parseDuration } from '../../../../../../alerting/common/parse_duration'; const customTheme = () => { return { @@ -60,35 +59,26 @@ const getTimezone = (uiSettings: IUiSettingsClient) => { return tzOffset; }; -const getDomain = (alertParams: any) => { - const VISUALIZE_TIME_WINDOW_MULTIPLIER = 5; - const fromExpression = `now-${alertParams.timeWindowSize * VISUALIZE_TIME_WINDOW_MULTIPLIER}${ - alertParams.timeWindowUnit - }`; - const toExpression = 'now'; - const fromMoment = dateMath.parse(fromExpression); - const toMoment = dateMath.parse(toExpression); - const visualizeTimeWindowFrom = fromMoment ? fromMoment.valueOf() : 0; - const visualizeTimeWindowTo = toMoment ? toMoment.valueOf() : 0; +const getDomain = (alertInterval: string) => { + const VISUALIZE_INTERVALS = 30; + let intervalMillis: number; + + try { + intervalMillis = parseDuration(alertInterval); + } catch (err) { + intervalMillis = 1000 * 60; // default to one minute if not parseable + } + + const now = Date.now(); return { - min: visualizeTimeWindowFrom, - max: visualizeTimeWindowTo, + min: now - intervalMillis * VISUALIZE_INTERVALS, + max: now, }; }; -const getTimeBuckets = ( - uiSettings: IUiSettingsClient, - dataFieldsFormats: any, - alertParams: any -) => { - const domain = getDomain(alertParams); - const timeBuckets = new TimeBuckets(uiSettings, dataFieldsFormats); - timeBuckets.setBounds(domain); - return timeBuckets; -}; - interface Props { alertParams: IndexThresholdAlertParams; + alertInterval: string; aggregationTypes: { [key: string]: AggregationType }; comparators: { [key: string]: Comparator; @@ -96,8 +86,10 @@ interface Props { alertsContext: AlertsContextValue; } +type MetricResult = [number, number]; // [epochMillis, value] export const ThresholdVisualization: React.FunctionComponent<Props> = ({ alertParams, + alertInterval, aggregationTypes, comparators, alertsContext, @@ -119,18 +111,14 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<undefined | any>(undefined); - const [visualizationData, setVisualizationData] = useState<Record<string, any>>([]); + const [visualizationData, setVisualizationData] = useState<Record<string, MetricResult[]>>(); useEffect(() => { (async () => { try { setIsLoading(true); setVisualizationData( - await getThresholdAlertVisualizationData({ - model: alertWithoutActions, - visualizeOptions, - http, - }) + await getVisualizationData(alertWithoutActions, visualizeOptions, http) ); } catch (e) { if (toastNotifications) { @@ -167,15 +155,11 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({ } const chartsTheme = charts.theme.useChartsTheme(); - const domain = getDomain(alertParams); - const timeBuckets = new TimeBuckets(uiSettings, dataFieldsFormats); - timeBuckets.setBounds(domain); - const interval = timeBuckets.getInterval().expression; + const domain = getDomain(alertInterval); const visualizeOptions = { - rangeFrom: domain.min, - rangeTo: domain.max, - interval, - timezone: getTimezone(uiSettings), + rangeFrom: new Date(domain.min).toISOString(), + rangeTo: new Date(domain.max).toISOString(), + interval: alertInterval, }; // Fetching visualization data is independent of alert actions @@ -237,15 +221,10 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({ } }); }); - const dateFormatter = (d: number) => { - return moment(d) - .tz(timezone) - .format(getTimeBuckets(uiSettings, dataFieldsFormats, alertParams).getScaledDateFormat()); - }; + const dateFormatter = niceTimeFormatter([domain.min, domain.max]); const aggLabel = aggregationTypes[aggType].text; return ( <div data-test-subj="alertVisualizationChart"> - <EuiSpacer size="l" /> {alertVisualizationDataKeys.length ? ( <Chart size={['100%', 200]} renderer="canvas"> <Settings @@ -309,10 +288,28 @@ export const ThresholdVisualization: React.FunctionComponent<Props> = ({ /> </EuiCallOut> )} - <EuiSpacer size="l" /> </div> ); } return null; }; + +// convert the data from the visualization API into something easier to digest with charts +async function getVisualizationData(model: any, visualizeOptions: any, http: HttpSetup) { + const vizData = await getThresholdAlertVisualizationData({ + model, + visualizeOptions, + http, + }); + const result: Record<string, Array<[number, number]>> = {}; + + for (const groupMetrics of vizData.results) { + result[groupMetrics.group] = groupMetrics.metrics.map(metricResult => [ + Date.parse(metricResult[0]), + metricResult[1], + ]); + } + + return result; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx index 11786950d0f263..b49cdc3d7d8b83 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx @@ -5,15 +5,19 @@ */ import React, { createContext, useContext } from 'react'; -import { ActionType } from '../../types'; +import { HttpSetup, ToastsApi, ApplicationStart } from 'kibana/public'; +import { ActionTypeModel } from '../../types'; +import { TypeRegistry } from '../type_registry'; export interface ActionsConnectorsContextValue { - addFlyoutVisible: boolean; - editFlyoutVisible: boolean; - setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; - setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; - actionTypesIndex: Record<string, ActionType> | undefined; - reloadConnectors: () => Promise<void>; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + capabilities: ApplicationStart['capabilities']; + reloadConnectors?: () => Promise<void>; } const ActionsConnectorsContext = createContext<ActionsConnectorsContextValue>(null as any); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index a8578acc246364..1944cdeab7552a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -16,11 +16,11 @@ export interface AlertsContextValue<MetaData = Record<string, any>> { http: HttpSetup; alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; - uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; + uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/_index.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/_index.scss deleted file mode 100644 index b5755bc35b1c1f..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/_index.scss +++ /dev/null @@ -1,7 +0,0 @@ -.actConnectorModal { - z-index: 9000; -} - -.euiComboBoxOptionsList { - z-index: 10000; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index f7becb16c244a5..800863e46034e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -9,26 +9,21 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; +import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('action_connector_form', () => { - let deps: any; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -37,11 +32,7 @@ describe('action_connector_form', () => { show: true, }, }, - legacy: { - MANAGEMENT_BREADCRUMB: { set: () => {} } as any, - }, actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); 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 new file mode 100644 index 00000000000000..caed0caefe1093 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult, Alert, AlertAction } from '../../../types'; +import { ActionForm } from './action_form'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +describe('action_form', () => { + let deps: any; + const alertType = { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => <Fragment />, + }; + + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + + describe('action_form in alert', () => { + let wrapper: ReactWrapper<any>; + + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + actionTypeRegistry: actionTypeRegistry as any, + }; + actionTypeRegistry.list.mockReturnValue([actionType]); + actionTypeRegistry.has.mockReturnValue(true); + + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: alertType.id, + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: actionType.id, + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + wrapper = mountWithIntl( + <ActionForm + actions={initialAlert.actions} + messageVariables={['test var1', 'test var2']} + defaultActionGroupId={'default'} + setActionIdByIndex={(id: string, index: number) => { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={deps!.http} + actionTypeRegistry={deps!.actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={[ + { id: actionType.id, name: 'Test', enabled: true }, + { id: '.index', name: 'Index', enabled: true }, + ]} + toastNotifications={deps!.toastNotifications} + /> + ); + + // Wait for active space to resolve before requesting the component to update + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it('renders available action cards', async () => { + await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + expect(actionOption.exists()).toBeTruthy(); + }); + }); +}); 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 new file mode 100644 index 00000000000000..a43aa22026710a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -0,0 +1,512 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiButtonEmpty, +} from '@elastic/eui'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { + IErrorObject, + ActionTypeModel, + AlertAction, + ActionTypeIndex, + ActionConnector, + ActionType, +} from '../../../types'; +import { SectionLoading } from '../../components/section_loading'; +import { ConnectorAddModal } from './connector_add_modal'; +import { TypeRegistry } from '../../type_registry'; + +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +interface ActiveActionConnectorState { + actionTypeId: string; + index: number; +} + +export const ActionForm = ({ + actions, + defaultActionGroupId, + setActionIdByIndex, + setAlertProperty, + setActionParamsProperty, + http, + actionTypeRegistry, + actionTypes, + messageVariables, + defaultActionMessage, + toastNotifications, +}: ActionAccordionFormProps) => { + const [addModalVisible, setAddModalVisibility] = useState<boolean>(false); + const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>( + undefined + ); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState<boolean>(true); + const [connectors, setConnectors] = useState<ActionConnector[]>([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false); + const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); + + // load action types + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const registeredActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of registeredActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + } finally { + setIsLoadingActionTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadConnectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function loadConnectors() { + try { + const actionsResponse = await loadAllActions({ http }); + setConnectors(actionsResponse.data); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + } + + const actionsErrors = actions.reduce( + (acc: Record<string, { errors: IErrorObject }>, alertAction: AlertAction) => { + const actionType = actionTypeRegistry.get(alertAction.actionTypeId); + if (!actionType) { + return { ...acc }; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + return { ...acc, [alertAction.id]: actionValidationErrors }; + }, + {} + ) as Record<string, { errors: IErrorObject }>; + + const getSelectedOptions = (actionItemId: string) => { + const val = connectors.find(connector => connector.id === actionItemId); + if (!val) { + return []; + } + return [ + { + label: val.name, + value: val.name, + id: actionItemId, + }, + ]; + }; + + const getActionTypeForm = ( + actionItem: AlertAction, + actionConnector: ActionConnector, + index: number + ) => { + const optionsList = connectors + .filter( + connectorItem => + connectorItem.actionTypeId === actionItem.actionTypeId && + (connectorItem.id === actionItem.id || + !actions.find( + (existingAction: AlertAction) => + existingAction.id === connectorItem.id && existingAction.group === actionItem.group + )) + ) + .map(({ name, id }) => ({ + label: name, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const actionParamsErrors: { errors: IErrorObject } = + Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; + + return ( + <EuiAccordion + initialIsOpen={true} + key={index} + id={index.toString()} + className="euiAccordionForm" + buttonContentClassName="euiAccordionForm__button" + data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} + buttonContent={ + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type={actionTypeRegistered.iconClass} size="m" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiTitle size="s"> + <h5> + <FormattedMessage + defaultMessage="Action: {actionConnectorName}" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" + values={{ + actionConnectorName: actionConnector.name, + }} + /> + </h5> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + } + extraAction={ + <EuiButtonIcon + iconType="cross" + color="danger" + className="euiAccordionForm__extraAction" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', + { + defaultMessage: 'Delete', + } + )} + onClick={() => { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + <EuiFlexGroup component="div"> + <EuiFlexItem> + <EuiFormRow + fullWidth + label={ + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.actionIdLabel" + defaultMessage="{connectorInstance} instance" + values={{ + connectorInstance: actionTypesIndex + ? actionTypesIndex[actionConnector.actionTypeId].name + : actionConnector.actionTypeId, + }} + /> + } + labelAppend={ + <EuiButtonEmpty + size="xs" + onClick={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + <FormattedMessage + defaultMessage="Add new" + id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton" + /> + </EuiButtonEmpty> + } + > + <EuiComboBox + fullWidth + singleSelection={{ asPlainText: true }} + options={optionsList} + selectedOptions={getSelectedOptions(actionItem.id)} + onChange={selectedOptions => { + setActionIdByIndex(selectedOptions[0].id ?? '', index); + }} + isClearable={false} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="xl" /> + {ParamsFieldsComponent ? ( + <ParamsFieldsComponent + actionParams={actionItem.params as any} + index={index} + errors={actionParamsErrors.errors} + editAction={setActionParamsProperty} + messageVariables={messageVariables} + defaultMessage={defaultActionMessage ?? undefined} + /> + ) : null} + </EuiAccordion> + ); + }; + + const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + return ( + <EuiAccordion + initialIsOpen={true} + key={index} + id={index.toString()} + className="euiAccordionForm" + buttonContentClassName="euiAccordionForm__button" + data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} + buttonContent={ + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiIcon type={actionTypeRegistered.iconClass} size="m" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiTitle size="s"> + <h5> + <FormattedMessage + defaultMessage="Action: {actionConnectorName}" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" + values={{ + actionConnectorName: actionTypeRegistered.actionTypeTitle, + }} + /> + </h5> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + } + extraAction={ + <EuiButtonIcon + iconType="cross" + color="danger" + className="euiAccordionForm__extraAction" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', + { + defaultMessage: 'Delete', + } + )} + onClick={() => { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + <EuiEmptyPrompt + title={ + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.emptyConnectorsLabel" + defaultMessage="There are no {actionTypeName} connectors" + values={{ + actionTypeName, + }} + /> + } + actions={[ + <EuiButton + color="primary" + fill + data-test-subj="createActionConnectorButton" + onClick={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.addConnectorButtonLabel" + defaultMessage="Add {actionTypeName} connector" + values={{ + actionTypeName, + }} + /> + </EuiButton>, + ]} + /> + </EuiAccordion> + ); + }; + + function addActionType(actionTypeModel: ActionTypeModel) { + if (!defaultActionGroupId) { + toastNotifications!.addDanger({ + title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { + defaultMessage: 'Unable to add action, because default action group is not defined', + }), + }); + return; + } + setIsAddActionPanelOpen(false); + const actionTypeConnectors = connectors.filter( + field => field.actionTypeId === actionTypeModel.id + ); + let freeConnectors; + if (actionTypeConnectors.length > 0) { + // Should we allow adding multiple actions to the same connector under the alert? + freeConnectors = actionTypeConnectors.filter( + (actionConnector: ActionConnector) => + !actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) + ); + if (freeConnectors.length > 0) { + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(freeConnectors[0].id, actions.length - 1); + } + } + if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { + // if no connectors exists or all connectors is already assigned an action under current alert + // set actionType as id to be able to create new connector within the alert form + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(actions.length.toString(), actions.length - 1); + } + } + + const actionTypeNodes = actionTypesIndex + ? actionTypeRegistry.list().map(function(item, index) { + return actionTypesIndex[item.id] ? ( + <EuiKeyPadMenuItem + key={index} + data-test-subj={`${item.id}-ActionTypeSelectOption`} + label={actionTypesIndex[item.id].name} + onClick={() => addActionType(item)} + > + <EuiIcon size="xl" type={item.iconClass} /> + </EuiKeyPadMenuItem> + ) : null; + }) + : null; + + return ( + <Fragment> + {actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return getAddConnectorsForm(actionItem, index); + } + return getActionTypeForm(actionItem, actionConnector, index); + })} + <EuiSpacer size="m" /> + {isAddActionPanelOpen === false ? ( + <EuiButton + iconType="plusInCircle" + data-test-subj="addAlertActionButton" + onClick={() => setIsAddActionPanelOpen(true)} + > + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.addActionButtonLabel" + defaultMessage="Add action" + /> + </EuiButton> + ) : null} + {isAddActionPanelOpen ? ( + <Fragment> + <EuiTitle size="xs"> + <h5 id="alertActionTypeTitle"> + <FormattedMessage + defaultMessage="Actions: Select an action type" + id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle" + /> + </h5> + </EuiTitle> + <EuiSpacer /> + <EuiFlexGroup gutterSize="s" wrap> + {isLoadingActionTypes ? ( + <SectionLoading> + <FormattedMessage + id="xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription" + defaultMessage="Loading action types…" + /> + </SectionLoading> + ) : ( + actionTypeNodes + )} + </EuiFlexGroup> + </Fragment> + ) : null} + {actionTypesIndex && activeActionItem ? ( + <ConnectorAddModal + key={activeActionItem.index} + actionType={actionTypesIndex[activeActionItem.actionTypeId]} + addModalVisible={addModalVisible} + setAddModalVisibility={setAddModalVisibility} + postSaveEventHandler={(savedAction: ActionConnector) => { + connectors.push(savedAction); + setActionIdByIndex(savedAction.id, activeActionItem.index); + }} + actionTypeRegistry={actionTypeRegistry} + http={http} + toastNotifications={toastNotifications} + /> + ) : null} + </Fragment> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index c1c6d9d94e8106..4f098165033e79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -6,31 +6,28 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { + ActionsConnectorsContextProvider, + ActionsConnectorsContextValue, +} from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionTypeMenu } from './action_type_menu'; import { ValidationResult } from '../../../types'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let deps: any; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mockes.getStartServices(); deps = { - chrome, - docLinks, - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, - uiSettings: mockes.uiSettings, + toastNotifications: mockes.notifications.toasts, capabilities: { ...capabilities, actions: { @@ -39,11 +36,7 @@ describe('connector_add_flyout', () => { show: true, }, }, - legacy: { - MANAGEMENT_BREADCRUMB: { set: () => {} } as any, - }, actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); @@ -68,14 +61,10 @@ describe('connector_add_flyout', () => { const wrapper = mountWithIntl( <ActionsConnectorsContextProvider value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'first-action-type': { id: 'first-action-type', name: 'first', enabled: true }, - 'second-action-type': { id: 'second-action-type', name: 'second', enabled: true }, - }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + capabilities: deps!.capabilities, + toastNotifications: deps!.toastNotifications, reloadConnectors: () => { return new Promise<void>(() => {}); }, @@ -83,12 +72,17 @@ describe('connector_add_flyout', () => { > <ActionTypeMenu onActionTypeChange={onActionTypeChange} - actionTypeRegistry={deps.actionTypeRegistry} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + }, + ]} /> </ActionsConnectorsContextProvider> ); - expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index ddd08cf6d6d797..a63665a68fb6b3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -3,24 +3,46 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui'; -import { ActionType, ActionTypeModel } from '../../../types'; +import { i18n } from '@kbn/i18n'; +import { ActionType, ActionTypeIndex } from '../../../types'; +import { loadActionTypes } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; -import { TypeRegistry } from '../../type_registry'; interface Props { onActionTypeChange: (actionType: ActionType) => void; - actionTypeRegistry: TypeRegistry<ActionTypeModel>; + actionTypes?: ActionType[]; } -export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props) => { - const { actionTypesIndex } = useActionsConnectorsContext(); - if (!actionTypesIndex) { - return null; - } +export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => { + const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext(); + const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); - const actionTypes = Object.entries(actionTypesIndex) + useEffect(() => { + (async () => { + try { + const availableActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of availableActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const registeredActionTypes = Object.entries(actionTypesIndex ?? []) .filter(([index]) => actionTypeRegistry.has(index)) .map(([index, actionType]) => { const actionTypeModel = actionTypeRegistry.get(index); @@ -33,7 +55,7 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props }; }); - const cardNodes = actionTypes + const cardNodes = registeredActionTypes .sort((a, b) => a.name.localeCompare(b.name)) .map((item, index) => { return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index 6b87002a1d2cfc..cf0edbe4224957 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -7,37 +7,28 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddFlyout } from './connector_add_flyout'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { + ActionsConnectorsContextProvider, + ActionsConnectorsContextValue, +} from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; -import { AppContextProvider } from '../../app_context'; -import { AppDeps } from '../../app'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let deps: AppDeps | null; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -46,9 +37,7 @@ describe('connector_add_flyout', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); @@ -71,24 +60,29 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - <AppContextProvider appDeps={deps}> - <ActionsConnectorsContextProvider - value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + <ActionsConnectorsContextProvider + value={{ + http: deps!.http, + toastNotifications: deps!.toastNotifications, + actionTypeRegistry: deps!.actionTypeRegistry, + capabilities: deps!.capabilities, + reloadConnectors: () => { + return new Promise<void>(() => {}); + }, + }} + > + <ConnectorAddFlyout + addFlyoutVisible={true} + setAddFlyoutVisibility={() => {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', }, - reloadConnectors: () => { - return new Promise<void>(() => {}); - }, - }} - > - <ConnectorAddFlyout /> - </ActionsConnectorsContextProvider> - </AppContextProvider> + ]} + /> + </ActionsConnectorsContextProvider> ); expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 1eabf2441da4f2..1b861167810847 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -20,18 +20,33 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionTypeMenu } from './action_type_menu'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { ActionType, ActionConnector, IErrorObject } from '../../../types'; -import { useAppDependencies } from '../../app_context'; import { connectorReducer } from './connector_reducer'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; + +export interface ConnectorAddFlyoutProps { + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; + actionTypes?: ActionType[]; +} -export const ConnectorAddFlyout = () => { +export const ConnectorAddFlyout = ({ + addFlyoutVisible, + setAddFlyoutVisibility, + actionTypes, +}: ConnectorAddFlyoutProps) => { let hasErrors = false; - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); + const { + http, + toastNotifications, + capabilities, + actionTypeRegistry, + reloadConnectors, + } = useActionsConnectorsContext(); const [actionType, setActionType] = useState<ActionType | undefined>(undefined); // hooks @@ -48,11 +63,6 @@ export const ConnectorAddFlyout = () => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; - const { - addFlyoutVisible, - setAddFlyoutVisibility, - reloadConnectors, - } = useActionsConnectorsContext(); const [isSaving, setIsSaving] = useState<boolean>(false); const closeFlyout = useCallback(() => { @@ -79,10 +89,7 @@ export const ConnectorAddFlyout = () => { let actionTypeModel; if (!actionType) { currentForm = ( - <ActionTypeMenu - onActionTypeChange={onActionTypeChange} - actionTypeRegistry={actionTypeRegistry} - /> + <ActionTypeMenu onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} /> ); } else { actionTypeModel = actionTypeRegistry.get(actionType.id); @@ -108,17 +115,19 @@ export const ConnectorAddFlyout = () => { const onActionConnectorSave = async (): Promise<ActionConnector | undefined> => await createActionConnector({ http, connector }) .then(savedConnector => { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Created '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); + if (toastNotifications) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Created '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + } return savedConnector; }) .catch(errorRes => { @@ -218,7 +227,9 @@ export const ConnectorAddFlyout = () => { setIsSaving(false); if (savedAction) { closeFlyout(); - reloadConnectors(); + if (reloadConnectors) { + reloadConnectors(); + } } }} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.scss new file mode 100644 index 00000000000000..f8fa882cd617da --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.scss @@ -0,0 +1,3 @@ +.actConnectorModal { + z-index: 9000; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index d9f3e98919d766..31d801bb340f32 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -7,35 +7,24 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddModal } from './connector_add_modal'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; -import { AppDeps } from '../../app'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; +import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_modal', () => { - let deps: AppDeps | null; + let deps: ActionsConnectorsContextValue; beforeAll(async () => { const mocks = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mocks.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mocks.notifications.toasts, - injectedMetadata: mocks.injectedMetadata, http: mocks.http, - uiSettings: mocks.uiSettings, capabilities: { ...capabilities, actions: { @@ -44,9 +33,7 @@ describe('connector_add_modal', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: {} as any, }; }); it('renders connector modal form if addModalVisible is true', () => { @@ -75,30 +62,14 @@ describe('connector_add_modal', () => { const wrapper = deps ? mountWithIntl( - <ActionsConnectorsContextProvider - value={{ - addFlyoutVisible: true, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, - }, - reloadConnectors: () => { - return new Promise<void>(() => {}); - }, - }} - > - <ConnectorAddModal - addModalVisible={true} - setAddModalVisibility={() => {}} - actionType={actionType} - http={deps.http} - actionTypeRegistry={deps.actionTypeRegistry} - alertTypeRegistry={deps.alertTypeRegistry} - toastNotifications={deps.toastNotifications} - /> - </ActionsConnectorsContextProvider> + <ConnectorAddModal + addModalVisible={true} + setAddModalVisibility={() => {}} + actionType={actionType} + http={deps.http} + actionTypeRegistry={deps.actionTypeRegistry} + toastNotifications={deps.toastNotifications} + /> ) : undefined; expect(wrapper?.find('EuiModalHeader')).toHaveLength(1); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 64862927256603..fcf3a309a1fc9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -19,16 +19,11 @@ import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { HttpSetup, ToastsApi } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { - ActionType, - ActionConnector, - IErrorObject, - AlertTypeModel, - ActionTypeModel, -} from '../../../types'; +import { ActionType, ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; +import './connector_add_modal.scss'; interface ConnectorAddModalProps { actionType: ActionType; @@ -36,7 +31,6 @@ interface ConnectorAddModalProps { setAddModalVisibility: React.Dispatch<React.SetStateAction<boolean>>; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; - alertTypeRegistry: TypeRegistry<AlertTypeModel>; actionTypeRegistry: TypeRegistry<ActionTypeModel>; toastNotifications?: Pick< ToastsApi, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index a82003759d9736..f9aa2cad8bfc60 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -11,8 +11,6 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; import { ConnectorEditFlyout } from './connector_edit_flyout'; import { AppContextProvider } from '../../app_context'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); let deps: any; @@ -22,18 +20,11 @@ describe('connector_edit_flyout', () => { const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, application: { capabilities }, }, ] = await mockes.getStartServices(); deps = { - chrome, - docLinks, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, capabilities: { @@ -44,7 +35,6 @@ describe('connector_edit_flyout', () => { show: true, }, }, - setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, }; @@ -82,19 +72,20 @@ describe('connector_edit_flyout', () => { <AppContextProvider appDeps={deps}> <ActionsConnectorsContextProvider value={{ - addFlyoutVisible: false, - setAddFlyoutVisibility: state => {}, - editFlyoutVisible: true, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'test-action-type-id': { id: 'test-action-type-id', name: 'test', enabled: true }, - }, + http: deps.http, + toastNotifications: deps.toastNotifications, + capabilities: deps.capabilities, + actionTypeRegistry: deps.actionTypeRegistry, reloadConnectors: () => { return new Promise<void>(() => {}); }, }} > - <ConnectorEditFlyout initialConnector={connector} /> + <ConnectorEditFlyout + initialConnector={connector} + editFlyoutVisible={true} + setEditFlyoutVisibility={state => {}} + /> </ActionsConnectorsContextProvider> </AppContextProvider> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 6fe555fd74b398..c52bb8cc08f6f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -19,27 +19,33 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { useAppDependencies } from '../../app_context'; import { ActionConnectorTableItem, ActionConnector, IErrorObject } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { updateActionConnector } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; export interface ConnectorEditProps { initialConnector: ActionConnectorTableItem; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>; } -export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => { +export const ConnectorEditFlyout = ({ + initialConnector, + editFlyoutVisible, + setEditFlyoutVisibility, +}: ConnectorEditProps) => { let hasErrors = false; - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); - const canSave = hasSaveActionsCapability(capabilities); const { - editFlyoutVisible, - setEditFlyoutVisibility, + http, + toastNotifications, + capabilities, + actionTypeRegistry, reloadConnectors, } = useActionsConnectorsContext(); + const canSave = hasSaveActionsCapability(capabilities); const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: { ...initialConnector, secrets: {} }, @@ -63,17 +69,19 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => const onActionConnectorSave = async (): Promise<ActionConnector | undefined> => await updateActionConnector({ http, connector, id: connector.id }) .then(savedConnector => { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Updated '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); + if (toastNotifications) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Updated '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + } return savedConnector; }) .catch(errorRes => { @@ -151,7 +159,9 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => setIsSaving(false); if (savedAction) { closeFlyout(); - reloadConnectors(); + if (reloadConnectors) { + reloadConnectors(); + } } }} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts index aac7a514948d14..52ee1efbdaf9fe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts @@ -6,3 +6,4 @@ export { ConnectorAddFlyout } from './connector_add_flyout'; export { ConnectorEditFlyout } from './connector_edit_flyout'; +export { ActionForm } from './action_form'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/_index.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/_index.scss deleted file mode 100644 index 98c6c2a307a748..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'actions_connectors_list'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index f48e27791419dc..9444b31a8b78f2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -18,16 +18,17 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; import { useAppDependencies } from '../../../app_context'; import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api'; import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form'; import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities'; import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal'; +import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; +import './actions_connectors_list.scss'; export const ActionsConnectorsList: React.FunctionComponent = () => { - const { http, toastNotifications, capabilities } = useAppDependencies(); + const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); const canDelete = hasDeleteActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities); @@ -377,19 +378,23 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { {data.length === 0 && !canSave && noPermissionPrompt} <ActionsConnectorsContextProvider value={{ - addFlyoutVisible, - setAddFlyoutVisibility, - editFlyoutVisible, - setEditFlyoutVisibility, - actionTypesIndex, + actionTypeRegistry, + http, + capabilities, + toastNotifications, reloadConnectors: loadActions, }} > - <ConnectorAddFlyout /> + <ConnectorAddFlyout + addFlyoutVisible={addFlyoutVisible} + setAddFlyoutVisibility={setAddFlyoutVisibility} + /> {editedConnectorItem ? ( <ConnectorEditFlyout key={editedConnectorItem.id} initialConnector={editedConnectorItem} + editFlyoutVisible={editFlyoutVisible} + setEditFlyoutVisibility={setEditFlyoutVisibility} /> ) : null} </ActionsConnectorsContextProvider> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index d216b4d2a4afe4..4ebeba3924faf7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -20,7 +20,7 @@ describe('alert_edit', () => { let deps: any; let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { const mockes = coreMock.createSetup(); deps = { toastNotifications: mockes.notifications.toasts, @@ -122,9 +122,10 @@ describe('alert_edit', () => { await nextTick(); wrapper.update(); }); - }); + } - it('renders alert add flyout', () => { + it('renders alert add flyout', async () => { + await setup(); expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 0c22ce0fca80c2..6119b407a6590d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -4,22 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ValidationResult, Alert } from '../../../types'; import { AlertForm } from './alert_form'; -import { AppDeps } from '../../app'; -import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { AlertsContextProvider } from '../../context/alerts_context'; +import { coreMock } from 'src/core/public/mocks'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); describe('alert_form', () => { - let deps: AppDeps | null; + let deps: any; const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -44,42 +41,19 @@ describe('alert_form', () => { actionConnectorFields: null, actionParamsFields: null, }; - beforeAll(async () => { - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities }, - }, - ] = await mockes.getStartServices(); - deps = { - chrome, - docLinks, - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, - http: mockes.http, - uiSettings: mockes.uiSettings, - dataPlugin: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - capabilities: { - ...capabilities, - siem: { - 'alerting:show': true, - 'alerting:save': true, - 'alerting:delete': false, - }, - }, - setBreadcrumbs: jest.fn(), - actionTypeRegistry: actionTypeRegistry as any, - alertTypeRegistry: alertTypeRegistry as any, - }; - }); describe('alert_form create alert', () => { let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + uiSettings: mockes.uiSettings, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.list.mockReturnValue([actionType]); @@ -99,47 +73,49 @@ describe('alert_form', () => { mutedInstanceIds: [], } as unknown) as Alert; + wrapper = mountWithIntl( + <AlertsContextProvider + value={{ + reloadAlerts: () => { + return new Promise<void>(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + <AlertForm + alert={initialAlert} + dispatch={() => {}} + errors={{ name: [] }} + serverError={null} + /> + </AlertsContextProvider> + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - <AlertsContextProvider - value={{ - reloadAlerts: () => { - return new Promise<void>(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - <AlertForm - alert={initialAlert} - dispatch={() => {}} - errors={{ name: [] }} - serverError={null} - /> - </AlertsContextProvider> - ); - } + await nextTick(); + wrapper.update(); }); + } - await waitForRender(wrapper); - }); - - it('renders alert name', () => { + it('renders alert name', async () => { + await setup(); const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); expect(alertNameField.exists()).toBeTruthy(); expect(alertNameField.first().prop('value')).toBe('test'); }); - it('renders registered selected alert type', () => { + it('renders registered selected alert type', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find('[data-test-subj="my-alert-type-SelectOption"]'); expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - it('renders registered action types', () => { + it('renders registered action types', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find( '[data-test-subj=".server-log-ActionTypeSelectOption"]' ); @@ -150,7 +126,15 @@ describe('alert_form', () => { describe('alert_form edit alert', () => { let wrapper: ReactWrapper<any>; - beforeAll(async () => { + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + uiSettings: mockes.uiSettings, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.get.mockReturnValue(alertType); alertTypeRegistry.has.mockReturnValue(true); @@ -173,57 +157,45 @@ describe('alert_form', () => { mutedInstanceIds: [], } as unknown) as Alert; + wrapper = mountWithIntl( + <AlertsContextProvider + value={{ + reloadAlerts: () => { + return new Promise<void>(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + <AlertForm + alert={initialAlert} + dispatch={() => {}} + errors={{ name: [] }} + serverError={null} + /> + </AlertsContextProvider> + ); + await act(async () => { - if (deps) { - wrapper = mountWithIntl( - <AlertsContextProvider - value={{ - reloadAlerts: () => { - return new Promise<void>(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - }} - > - <AlertForm - alert={initialAlert} - dispatch={() => {}} - errors={{ name: [] }} - serverError={null} - /> - </AlertsContextProvider> - ); - } + await nextTick(); + wrapper.update(); }); + } - await waitForRender(wrapper); - }); - - it('renders alert name', () => { + it('renders alert name', async () => { + await setup(); const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); expect(alertNameField.exists()).toBeTruthy(); expect(alertNameField.first().prop('value')).toBe('test'); }); - it('renders registered selected alert type', () => { + it('renders registered selected alert type', async () => { + await setup(); const alertTypeSelectOptions = wrapper.find('[data-test-subj="selectedAlertTypeTitle"]'); expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - - it('renders registered action types', () => { - const actionTypeSelectOptions = wrapper.find( - '[data-test-subj="my-action-type-ActionTypeSelectOption"]' - ); - expect(actionTypeSelectOptions.exists()).toBeTruthy(); - }); }); - - async function waitForRender(wrapper: ReactWrapper<any, any>) { - await Promise.resolve(); - await Promise.resolve(); - wrapper.update(); - } }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b875fae75c7dfd..240e05c3a6e43b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -7,7 +7,6 @@ import React, { Fragment, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -22,29 +21,15 @@ import { EuiFieldNumber, EuiSelect, EuiIconTip, - EuiAccordion, EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiHorizontalRule, } from '@elastic/eui'; import { loadAlertTypes } from '../../lib/alert_api'; -import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; import { AlertReducerAction } from './alert_reducer'; -import { - AlertTypeModel, - Alert, - IErrorObject, - ActionTypeModel, - AlertAction, - ActionTypeIndex, - ActionConnector, - AlertTypeIndex, -} from '../../../types'; -import { SectionLoading } from '../../components/section_loading'; -import { ConnectorAddModal } from '../action_connector_form/connector_add_modal'; +import { AlertTypeModel, Alert, IErrorObject, AlertAction, AlertTypeIndex } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { useAlertsContext } from '../../context/alerts_context'; +import { ActionForm } from '../action_connector_form/action_form'; export function validateBaseProperties(alertObject: Alert) { const validationResult = { errors: {} }; @@ -89,11 +74,6 @@ interface AlertFormProps { canChangeTrigger?: boolean; // to hide Change trigger button } -interface ActiveActionConnectorState { - actionTypeId: string; - index: number; -} - export const AlertForm = ({ alert, canChangeTrigger = true, @@ -108,9 +88,6 @@ export const AlertForm = ({ alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null ); - const [addModalVisible, setAddModalVisibility] = useState<boolean>(false); - const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false); - const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState<AlertTypeIndex | undefined>(undefined); const [alertInterval, setAlertInterval] = useState<number>( alert.schedule.interval ? parseInt(alert.schedule.interval.replace(/^[A-Za-z]+$/, ''), 0) : 1 @@ -124,39 +101,7 @@ export const AlertForm = ({ const [alertThrottleUnit, setAlertThrottleUnit] = useState<string>( alert.throttle ? alert.throttle.replace((alertThrottle ?? '').toString(), '') : 'm' ); - const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState<boolean>(true); - const [connectors, setConnectors] = useState<ActionConnector[]>([]); const [defaultActionGroupId, setDefaultActionGroupId] = useState<string | undefined>(undefined); - const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>( - undefined - ); - - // load action types - useEffect(() => { - (async () => { - try { - setIsLoadingActionTypes(true); - const actionTypes = await loadActionTypes({ http }); - const index: ActionTypeIndex = {}; - for (const actionTypeItem of actionTypes) { - index[actionTypeItem.id] = actionTypeItem; - } - setActionTypesIndex(index); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', - { defaultMessage: 'Unable to load action types' } - ), - }); - } - } finally { - setIsLoadingActionTypes(false); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // load alert types useEffect(() => { @@ -172,24 +117,17 @@ export const AlertForm = ({ } setAlertTypesIndex(index); } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', - { defaultMessage: 'Unable to load alert types' } - ), - }); - } + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', + { defaultMessage: 'Unable to load alert types' } + ), + }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - loadConnectors(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const setAlertProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); }; @@ -202,93 +140,20 @@ export const AlertForm = ({ dispatch({ command: { type: 'setScheduleProperty' }, payload: { key, value } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; - const setActionProperty = (key: string, value: any, index: number) => { dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; - - async function loadConnectors() { - try { - const actionsResponse = await loadAllActions({ http }); - setConnectors(actionsResponse.data); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', - { - defaultMessage: 'Unable to load connectors', - } - ), - }); - } - } - } + const setActionParamsProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }; - const actionsErrors = alert.actions.reduce( - (acc: Record<string, { errors: IErrorObject }>, alertAction: AlertAction) => { - const actionType = actionTypeRegistry.get(alertAction.actionTypeId); - if (!actionType) { - return { ...acc }; - } - const actionValidationErrors = actionType.validateParams(alertAction.params); - return { ...acc, [alertAction.id]: actionValidationErrors }; - }, - {} - ); + const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; const AlertParamsExpressionComponent = alertTypeModel ? alertTypeModel.alertParamsExpression : null; - function addActionType(actionTypeModel: ActionTypeModel) { - if (!defaultActionGroupId) { - toastNotifications!.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { - defaultMessage: 'Unable to add action, because default action group is not defined', - }), - }); - return; - } - setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( - field => field.actionTypeId === actionTypeModel.id - ); - let freeConnectors; - if (actionTypeConnectors.length > 0) { - // Should we allow adding multiple actions to the same connector under the alert? - freeConnectors = actionTypeConnectors.filter( - (actionConnector: ActionConnector) => - !alert.actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) - ); - if (freeConnectors.length > 0) { - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', freeConnectors[0].id, alert.actions.length - 1); - } - } - if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { - // if no connectors exists or all connectors is already assigned an action under current alert - // set actionType as id to be able to create new connector within the alert form - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', alert.actions.length, alert.actions.length - 1); - } - } - const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { return ( <EuiKeyPadMenuItem @@ -308,293 +173,6 @@ export const AlertForm = ({ ); }); - const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) { - return ( - <EuiKeyPadMenuItem - key={index} - data-test-subj={`${item.id}-ActionTypeSelectOption`} - label={actionTypesIndex ? actionTypesIndex[item.id].name : item.id} - onClick={() => addActionType(item)} - > - <EuiIcon size="xl" type={item.iconClass} /> - </EuiKeyPadMenuItem> - ); - }); - - const getSelectedOptions = (actionItemId: string) => { - const val = connectors.find(connector => connector.id === actionItemId); - if (!val) { - return []; - } - return [ - { - label: val.name, - value: val.name, - id: actionItemId, - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - index: number - ) => { - const optionsList = connectors - .filter( - connectorItem => - connectorItem.actionTypeId === actionItem.actionTypeId && - (connectorItem.id === actionItem.id || - !alert.actions.find( - (existingAction: AlertAction) => - existingAction.id === connectorItem.id && existingAction.group === actionItem.group - )) - ) - .map(({ name, id }) => ({ - label: name, - key: id, - id, - })); - const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; - const actionParamsErrors: { errors: IErrorObject } = - Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; - - return ( - <EuiAccordion - initialIsOpen={true} - key={index} - id={index.toString()} - className="euiAccordionForm" - buttonContentClassName="euiAccordionForm__button" - data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} - buttonContent={ - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiIcon type={actionTypeRegisterd.iconClass} size="m" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="s"> - <h5> - <FormattedMessage - defaultMessage="Action: {actionConnectorName}" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" - values={{ - actionConnectorName: actionConnector.name, - }} - /> - </h5> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - } - extraAction={ - <EuiButtonIcon - iconType="cross" - color="danger" - className="euiAccordionForm__extraAction" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', - { - defaultMessage: 'Delete', - } - )} - onClick={() => { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - <EuiFlexGroup component="div"> - <EuiFlexItem> - <EuiFormRow - fullWidth - label={ - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.actionIdLabel" - defaultMessage="{connectorInstance} instance" - values={{ - connectorInstance: actionTypesIndex - ? actionTypesIndex[actionConnector.actionTypeId].name - : actionConnector.actionTypeId, - }} - /> - } - labelAppend={ - <EuiButtonEmpty - size="xs" - onClick={() => { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - <FormattedMessage - defaultMessage="Add new" - id="xpack.triggersActionsUI.sections.alertForm.addNewConnectorEmptyButton" - /> - </EuiButtonEmpty> - } - > - <EuiComboBox - fullWidth - singleSelection={{ asPlainText: true }} - options={optionsList} - selectedOptions={getSelectedOptions(actionItem.id)} - onChange={selectedOptions => { - setActionProperty('id', selectedOptions[0].id, index); - }} - isClearable={false} - /> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="xl" /> - {ParamsFieldsComponent ? ( - <ParamsFieldsComponent - actionParams={actionItem.params as any} - index={index} - errors={actionParamsErrors.errors} - editAction={setActionParamsProperty} - messageVariables={ - alertTypesIndex && alertTypesIndex[alert.alertTypeId] - ? alertTypesIndex[alert.alertTypeId].actionVariables - : undefined - } - defaultMessage={alertTypeModel?.defaultActionMessage ?? undefined} - /> - ) : null} - </EuiAccordion> - ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegisterd = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - return ( - <EuiAccordion - initialIsOpen={true} - key={index} - id={index.toString()} - className="euiAccordionForm" - buttonContentClassName="euiAccordionForm__button" - data-test-subj={`alertActionAccordion-${defaultActionGroupId}`} - buttonContent={ - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiIcon type={actionTypeRegisterd.iconClass} size="m" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiTitle size="s"> - <h5> - <FormattedMessage - defaultMessage="Action: {actionConnectorName}" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle" - values={{ - actionConnectorName: actionTypeRegisterd.actionTypeTitle, - }} - /> - </h5> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - } - extraAction={ - <EuiButtonIcon - iconType="cross" - color="danger" - className="euiAccordionForm__extraAction" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel', - { - defaultMessage: 'Delete', - } - )} - onClick={() => { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - <EuiEmptyPrompt - title={ - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.emptyConnectorsLabel" - defaultMessage="There are no {actionTypeName} connectors" - values={{ - actionTypeName, - }} - /> - } - actions={[ - <EuiButton - color="primary" - fill - data-test-subj="createActionConnectorButton" - onClick={() => { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.addConnectorButtonLabel" - defaultMessage="Add {actionTypeName} connector" - values={{ - actionTypeName, - }} - /> - </EuiButton>, - ]} - /> - </EuiAccordion> - ); - }; - - const selectedGroupActions = ( - <Fragment> - {alert.actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find(field => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - return getActionTypeForm(actionItem, actionConnector, index); - })} - <EuiSpacer size="m" /> - {isAddActionPanelOpen === false ? ( - <EuiButton - iconType="plusInCircle" - data-test-subj="addAlertActionButton" - onClick={() => setIsAddActionPanelOpen(true)} - > - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.addActionButtonLabel" - defaultMessage="Add action" - /> - </EuiButton> - ) : null} - </Fragment> - ); - const alertTypeDetails = ( <Fragment> <EuiHorizontalRule /> @@ -632,38 +210,34 @@ export const AlertForm = ({ {AlertParamsExpressionComponent ? ( <AlertParamsExpressionComponent alertParams={alert.params} + alertInterval={`${alertInterval ?? 1}${alertIntervalUnit}`} errors={errors} setAlertParams={setAlertParams} setAlertProperty={setAlertProperty} alertsContext={alertsContext} /> ) : null} - <EuiSpacer size="xl" /> - {selectedGroupActions} - {isAddActionPanelOpen ? ( - <Fragment> - <EuiTitle size="xs"> - <h5 id="alertActionTypeTitle"> - <FormattedMessage - defaultMessage="Actions: Select an action type" - id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle" - /> - </h5> - </EuiTitle> - <EuiSpacer /> - <EuiFlexGroup gutterSize="s" wrap> - {isLoadingActionTypes ? ( - <SectionLoading> - <FormattedMessage - id="xpack.triggersActionsUI.sections.alertForm.loadingActionTypesDescription" - defaultMessage="Loading action types…" - /> - </SectionLoading> - ) : ( - actionTypeNodes - )} - </EuiFlexGroup> - </Fragment> + {defaultActionGroupId ? ( + <ActionForm + actions={alert.actions} + messageVariables={ + alertTypesIndex && alertTypesIndex[alert.alertTypeId] + ? alertTypesIndex[alert.alertTypeId].actionVariables + : undefined + } + defaultActionGroupId={defaultActionGroupId} + setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} + setAlertProperty={(updatedActions: AlertAction[]) => + setAlertProperty('actions', updatedActions) + } + setActionParamsProperty={(key: string, value: any, index: number) => + setActionParamsProperty(key, value, index) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={alertTypeModel?.defaultActionMessage} + toastNotifications={toastNotifications} + /> ) : null} </Fragment> ); @@ -862,22 +436,6 @@ export const AlertForm = ({ </EuiFlexGroup> </Fragment> )} - {actionTypesIndex && activeActionItem ? ( - <ConnectorAddModal - key={activeActionItem.index} - actionType={actionTypesIndex[activeActionItem.actionTypeId]} - addModalVisible={addModalVisible} - setAddModalVisibility={setAddModalVisibility} - postSaveEventHandler={(savedAction: ActionConnector) => { - connectors.push(savedAction); - setActionProperty('id', savedAction.id, activeActionItem.index); - }} - actionTypeRegistry={actionTypeRegistry} - alertTypeRegistry={alertTypeRegistry} - http={http} - toastNotifications={toastNotifications} - /> - ) : null} </EuiForm> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 0be0a919112f8d..fbffd5c2f999d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -8,7 +8,14 @@ import { PluginInitializerContext } from 'src/core/public'; import { Plugin } from './plugin'; export { AlertsContextProvider } from './application/context/alerts_context'; +export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; +export { ActionForm } from './application/sections/action_connector_form'; +export { AlertAction, Alert } from './types'; +export { + ConnectorAddFlyout, + ConnectorEditFlyout, +} from './application/sections/action_connector_form'; export function plugin(ctx: PluginInitializerContext) { return new Plugin(ctx); diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 459197d80d7aa5..9f975cba3c0d1f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -22,7 +22,10 @@ export interface TriggersAndActionsUIPublicPluginSetup { alertTypeRegistry: TypeRegistry<AlertTypeModel>; } -export type Start = void; +export interface TriggersAndActionsUIPublicPluginStart { + actionTypeRegistry: TypeRegistry<ActionTypeModel>; + alertTypeRegistry: TypeRegistry<AlertTypeModel>; +} interface PluginsStart { data: DataPublicPluginStart; @@ -30,7 +33,9 @@ interface PluginsStart { management: ManagementStart; } -export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup, Start> { +export class Plugin + implements + CorePlugin<TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart> { private actionTypeRegistry: TypeRegistry<ActionTypeModel>; private alertTypeRegistry: TypeRegistry<AlertTypeModel>; @@ -57,44 +62,46 @@ export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup, }; } - public start(core: CoreStart, plugins: PluginsStart) { + public start(core: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart { const { capabilities } = core.application; const canShowActions = hasShowActionsCapability(capabilities); const canShowAlerts = hasShowAlertsCapability(capabilities); // Don't register routes when user doesn't have access to the application - if (!canShowActions && !canShowAlerts) { - return; + if (canShowActions || canShowAlerts) { + plugins.management.sections.getSection('kibana')!.registerApp({ + id: 'triggersActions', + title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { + defaultMessage: 'Alerts and Actions', + }), + order: 7, + mount: params => { + boot({ + dataPlugin: plugins.data, + charts: plugins.charts, + element: params.element, + toastNotifications: core.notifications.toasts, + injectedMetadata: core.injectedMetadata, + http: core.http, + uiSettings: core.uiSettings, + docLinks: core.docLinks, + chrome: core.chrome, + savedObjects: core.savedObjects.client, + I18nContext: core.i18n.Context, + capabilities: core.application.capabilities, + setBreadcrumbs: params.setBreadcrumbs, + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }); + return () => {}; + }, + }); } - - plugins.management.sections.getSection('kibana')!.registerApp({ - id: 'triggersActions', - title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { - defaultMessage: 'Alerts and Actions', - }), - order: 7, - mount: params => { - boot({ - dataPlugin: plugins.data, - charts: plugins.charts, - element: params.element, - toastNotifications: core.notifications.toasts, - injectedMetadata: core.injectedMetadata, - http: core.http, - uiSettings: core.uiSettings, - docLinks: core.docLinks, - chrome: core.chrome, - savedObjects: core.savedObjects.client, - I18nContext: core.i18n.Context, - capabilities: core.application.capabilities, - setBreadcrumbs: params.setBreadcrumbs, - actionTypeRegistry: this.actionTypeRegistry, - alertTypeRegistry: this.alertTypeRegistry, - }); - return () => {}; - }, - }); + return { + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }; } public stop() {} diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index a0c12154988a1e..ceb3a6dd60166f 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -28,6 +28,19 @@ export enum ReindexStatus { } export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; + +export interface QueueSettings extends SavedObjectAttributes { + queuedAt: number; +} + +export interface ReindexOptions extends SavedObjectAttributes { + /** + * Set this key to configure a reindex operation as part of a + * batch to be run in series. + */ + queueSettings?: QueueSettings; +} + export interface ReindexOperation extends SavedObjectAttributes { indexName: string; newIndexName: string; @@ -40,6 +53,15 @@ export interface ReindexOperation extends SavedObjectAttributes { // This field is only used for the singleton IndexConsumerType documents. runningReindexCount: number | null; + + /** + * Options for the reindexing strategy. + * + * @remark + * Marked as optional for backwards compatibility. We should still + * be able to handle older ReindexOperation objects. + */ + reindexOptions?: ReindexOptions; } export type ReindexSavedObject = SavedObject<ReindexOperation>; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts index e7636eea664791..6182a82f6f1bdd 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.ts @@ -90,9 +90,9 @@ export const esVersionCheck = async ( } }; -export const versionCheckHandlerWrapper = (handler: RequestHandler<any, any, any>) => async ( +export const versionCheckHandlerWrapper = <P, Q, B>(handler: RequestHandler<P, Q, B>) => async ( ctx: RequestHandlerContext, - request: KibanaRequest, + request: KibanaRequest<P, Q, B>, response: KibanaResponseFactory ) => { const errorResponse = await esVersionCheck(ctx, response); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index b7bc197fbd162b..59922abd3e635a 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -12,6 +12,7 @@ import { ReindexTaskFailed, ReindexAlreadyInProgress, MultipleReindexJobsFound, + ReindexCannotBeCancelled, } from './error_symbols'; export class ReindexError extends Error { @@ -32,4 +33,5 @@ export const error = { reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), + reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index 9e49d280d1be22..d5e8d643f45955 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -11,5 +11,6 @@ export const CannotCreateIndex = Symbol('CannotCreateIndex'); export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); +export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts new file mode 100644 index 00000000000000..dbed7de13f010a --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts @@ -0,0 +1,56 @@ +/* + * 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 { flow } from 'fp-ts/lib/function'; +import { ReindexSavedObject } from '../../../common/types'; + +export interface SortedReindexSavedObjects { + /** + * Reindex objects sorted into this array represent Elasticsearch reindex tasks that + * have no inherent order and are considered to be processed in parallel. + */ + parallel: ReindexSavedObject[]; + + /** + * Reindex objects sorted into this array represent Elasticsearch reindex tasks that + * are consistently ordered (see {@link orderQueuedReindexOperations}) and should be + * processed in order. + */ + queue: ReindexSavedObject[]; +} + +const sortReindexOperations = (ops: ReindexSavedObject[]): SortedReindexSavedObjects => { + const parallel: ReindexSavedObject[] = []; + const queue: ReindexSavedObject[] = []; + for (const op of ops) { + if (op.attributes.reindexOptions?.queueSettings) { + queue.push(op); + } else { + parallel.push(op); + } + } + + return { + parallel, + queue, + }; +}; +const orderQueuedReindexOperations = ({ + parallel, + queue, +}: SortedReindexSavedObjects): SortedReindexSavedObjects => ({ + parallel, + // Sort asc + queue: queue.sort( + (a, b) => + a.attributes.reindexOptions!.queueSettings!.queuedAt - + b.attributes.reindexOptions!.queueSettings!.queuedAt + ), +}); + +export const sortAndOrderReindexOperations = flow( + sortReindexOperations, + orderQueuedReindexOperations +); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index 2ae340f12d80cb..422e78c2f12ad9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -11,6 +11,7 @@ import { IndexGroup, REINDEX_OP_TYPE, ReindexOperation, + ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -34,8 +35,9 @@ export interface ReindexActions { /** * Creates a new reindexOp, does not perform any pre-flight checks. * @param indexName + * @param opts Options for the reindex operation */ - createReindexOp(indexName: string): Promise<ReindexSavedObject>; + createReindexOp(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; /** * Deletes a reindexOp. @@ -150,7 +152,7 @@ export const reindexActionsFactory = ( // ----- Public interface return { - async createReindexOp(indexName: string) { + async createReindexOp(indexName: string, opts?: ReindexOptions) { return client.create<ReindexOperation>(REINDEX_OP_TYPE, { indexName, newIndexName: generateNewIndexName(indexName), @@ -161,6 +163,7 @@ export const reindexActionsFactory = ( reindexTaskPercComplete: null, errorMessage: null, runningReindexCount: null, + reindexOptions: opts, }); }, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 6c3b2c869dc7f9..886ea6761e3b73 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -215,7 +215,7 @@ describe('reindexService', () => { await service.createReindexOperation('myIndex'); - expect(actions.createReindexOp).toHaveBeenCalledWith('myIndex'); + expect(actions.createReindexOp).toHaveBeenCalledWith('myIndex', undefined); }); it('fails if index does not exist', async () => { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index b274743bdf2791..aa91b925b744b2 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -8,6 +8,7 @@ import { first } from 'rxjs/operators'; import { IndexGroup, + ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -51,8 +52,9 @@ export interface ReindexService { /** * Creates a new reindex operation for a given index. * @param indexName + * @param opts */ - createReindexOperation(indexName: string): Promise<ReindexSavedObject>; + createReindexOperation(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; /** * Retrieves all reindex operations that have the given status. @@ -83,8 +85,9 @@ export interface ReindexService { /** * Resumes the paused reindex operation for a given index. * @param indexName + * @param opts As with {@link createReindexOperation} we support this setting. */ - resumeReindexOperation(indexName: string): Promise<ReindexSavedObject>; + resumeReindexOperation(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; /** * Cancel an in-progress reindex operation for a given index. Only allowed when the @@ -517,7 +520,7 @@ export const reindexServiceFactory = ( } }, - async createReindexOperation(indexName: string) { + async createReindexOperation(indexName: string, opts?: ReindexOptions) { const indexExists = await callAsUser('indices.exists', { index: indexName }); if (!indexExists) { throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); @@ -539,7 +542,7 @@ export const reindexServiceFactory = ( } } - return actions.createReindexOp(indexName); + return actions.createReindexOp(indexName, opts); }, async findReindexOperation(indexName: string) { @@ -627,7 +630,7 @@ export const reindexServiceFactory = ( }); }, - async resumeReindexOperation(indexName: string) { + async resumeReindexOperation(indexName: string, opts?: ReindexOptions) { const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { @@ -642,7 +645,10 @@ export const reindexServiceFactory = ( throw new Error(`Reindex operation must be paused in order to be resumed.`); } - return actions.updateReindexOp(op, { status: ReindexStatus.inProgress }); + return actions.updateReindexOp(op, { + status: ReindexStatus.inProgress, + reindexOptions: opts, + }); }); }, @@ -650,11 +656,13 @@ export const reindexServiceFactory = ( const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { - throw new Error(`No reindex operation found for index ${indexName}`); + throw error.indexNotFound(`No reindex operation found for index ${indexName}`); } else if (reindexOp.attributes.status !== ReindexStatus.inProgress) { - throw new Error(`Reindex operation is not in progress`); + throw error.reindexCannotBeCancelled(`Reindex operation is not in progress`); } else if (reindexOp.attributes.lastCompletedStep !== ReindexStep.reindexStarted) { - throw new Error(`Reindex operation is not current waiting for reindex task to complete`); + throw error.reindexCannotBeCancelled( + `Reindex operation is not currently waiting for reindex task to complete` + ); } const resp = await callAsUser('tasks.cancel', { @@ -662,7 +670,7 @@ export const reindexServiceFactory = ( }); if (resp.node_failures && resp.node_failures.length > 0) { - throw new Error(`Could not cancel reindex.`); + throw error.reindexCannotBeCancelled(`Could not cancel reindex.`); } return reindexOp; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index bad6db62efe41a..482b9f280ad7e0 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -5,12 +5,12 @@ */ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; - import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; import { CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; import { ReindexService, reindexServiceFactory } from './reindex_service'; import { LicensingPluginSetup } from '../../../../licensing/server'; +import { sortAndOrderReindexOperations } from './op_utils'; const POLL_INTERVAL = 30000; // If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. @@ -105,15 +105,17 @@ export class ReindexWorker { private startUpdateOperationLoop = async () => { this.updateOperationLoopRunning = true; - while (this.inProgressOps.length > 0) { - this.log.debug(`Updating ${this.inProgressOps.length} reindex operations`); + try { + while (this.inProgressOps.length > 0) { + this.log.debug(`Updating ${this.inProgressOps.length} reindex operations`); - // Push each operation through the state machine and refresh. - await Promise.all(this.inProgressOps.map(this.processNextStep)); - await this.refresh(); + // Push each operation through the state machine and refresh. + await Promise.all(this.inProgressOps.map(this.processNextStep)); + await this.refresh(); + } + } finally { + this.updateOperationLoopRunning = false; } - - this.updateOperationLoopRunning = false; }; private pollForOperations = async () => { @@ -126,14 +128,28 @@ export class ReindexWorker { } }; - private refresh = async () => { + private updateInProgressOps = async () => { try { - this.inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); + const inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); + const { parallel, queue } = sortAndOrderReindexOperations(inProgressOps); + + const [firstOpInQueue] = queue; + + if (firstOpInQueue) { + this.log.debug( + `Queue detected; current length ${queue.length}, current item ReindexOperation(id: ${firstOpInQueue.id}, indexName: ${firstOpInQueue.attributes.indexName})` + ); + } + + this.inProgressOps = parallel.concat(firstOpInQueue ? [firstOpInQueue] : []); } catch (e) { - this.log.debug(`Could not fetch reindex operations from Elasticsearch`); + this.log.debug(`Could not fetch reindex operations from Elasticsearch, ${e.message}`); this.inProgressOps = []; } + }; + private refresh = async () => { + await this.updateInProgressOps(); // If there are operations in progress and we're not already updating operations, kick off the update loop if (!this.updateOperationLoopRunning) { this.startUpdateOperationLoop(); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts new file mode 100644 index 00000000000000..9f1d3e4021c3fa --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createReindexWorker, registerReindexIndicesRoutes } from './reindex_indices'; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts new file mode 100644 index 00000000000000..944b4a225d4425 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../../licensing/server'; + +import { ReindexOperation, ReindexOptions, ReindexStatus } from '../../../common/types'; + +import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; +import { reindexServiceFactory } from '../../lib/reindexing'; +import { CredentialStore } from '../../lib/reindexing/credential_store'; +import { error } from '../../lib/reindexing/error'; + +interface ReindexHandlerArgs { + savedObjects: SavedObjectsClientContract; + dataClient: IScopedClusterClient; + indexName: string; + log: Logger; + licensing: LicensingPluginSetup; + headers: Record<string, any>; + credentialStore: CredentialStore; + enqueue?: boolean; +} + +export const reindexHandler = async ({ + credentialStore, + dataClient, + headers, + indexName, + licensing, + log, + savedObjects, + enqueue, +}: ReindexHandlerArgs): Promise<ReindexOperation> => { + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(savedObjects, callAsCurrentUser); + const reindexService = reindexServiceFactory(callAsCurrentUser, reindexActions, log, licensing); + + if (!(await reindexService.hasRequiredPrivileges(indexName))) { + throw error.accessForbidden( + i18n.translate('xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch', { + defaultMessage: `You do not have adequate privileges to reindex "{indexName}".`, + values: { indexName }, + }) + ); + } + + const existingOp = await reindexService.findReindexOperation(indexName); + + const opts: ReindexOptions | undefined = enqueue + ? { queueSettings: { queuedAt: Date.now() } } + : undefined; + + // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. + const reindexOp = + existingOp && existingOp.attributes.status === ReindexStatus.paused + ? await reindexService.resumeReindexOperation(indexName, opts) + : await reindexService.createReindexOperation(indexName, opts); + + // Add users credentials for the worker to use + credentialStore.set(reindexOp, headers); + + return reindexOp.attributes; +}; diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts similarity index 70% rename from x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts rename to x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index 695bb6304cfdfb..af4f7f436ec811 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -5,9 +5,9 @@ */ import { kibanaResponseFactory } from 'src/core/server'; -import { licensingMock } from '../../../licensing/server/mocks'; -import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; -import { createRequestMock } from './__mocks__/request.mock'; +import { licensingMock } from '../../../../licensing/server/mocks'; +import { createMockRouter, MockRouter, routeHandlerContextMock } from '../__mocks__/routes.mock'; +import { createRequestMock } from '../__mocks__/request.mock'; const mockReindexService = { hasRequiredPrivileges: jest.fn(), @@ -21,18 +21,23 @@ const mockReindexService = { cancelReindexing: jest.fn(), }; -jest.mock('../lib/es_version_precheck', () => ({ +jest.mock('../../lib/es_version_precheck', () => ({ versionCheckHandlerWrapper: (a: any) => a, })); -jest.mock('../lib/reindexing', () => { +jest.mock('../../lib/reindexing', () => { return { reindexServiceFactory: () => mockReindexService, }; }); -import { IndexGroup, ReindexSavedObject, ReindexStatus, ReindexWarning } from '../../common/types'; -import { credentialStoreFactory } from '../lib/reindexing/credential_store'; +import { + IndexGroup, + ReindexSavedObject, + ReindexStatus, + ReindexWarning, +} from '../../../common/types'; +import { credentialStoreFactory } from '../../lib/reindexing/credential_store'; import { registerReindexIndicesRoutes } from './reindex_indices'; /** @@ -76,7 +81,7 @@ describe('reindex API', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('GET /api/upgrade_assistant/reindex/{indexName}', () => { @@ -161,7 +166,7 @@ describe('reindex API', () => { ); // It called create correctly - expect(mockReindexService.createReindexOperation).toHaveBeenCalledWith('theIndex'); + expect(mockReindexService.createReindexOperation).toHaveBeenCalledWith('theIndex', undefined); // It returned the right results expect(resp.status).toEqual(200); @@ -228,7 +233,7 @@ describe('reindex API', () => { kibanaResponseFactory ); // It called resume correctly - expect(mockReindexService.resumeReindexOperation).toHaveBeenCalledWith('theIndex'); + expect(mockReindexService.resumeReindexOperation).toHaveBeenCalledWith('theIndex', undefined); expect(mockReindexService.createReindexOperation).not.toHaveBeenCalled(); // It returned the right results @@ -255,6 +260,111 @@ describe('reindex API', () => { }); }); + describe('POST /api/upgrade_assistant/reindex/batch', () => { + const queueSettingsArg = { + queueSettings: { queuedAt: expect.any(Number) }, + }; + it('creates a collection of index operations', async () => { + mockReindexService.createReindexOperation + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex1' }, + }) + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex2' }, + }) + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex3' }, + }); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/reindex/batch', + })( + routeHandlerContextMock, + createRequestMock({ body: { indexNames: ['theIndex1', 'theIndex2', 'theIndex3'] } }), + kibanaResponseFactory + ); + + // It called create correctly + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 1, + 'theIndex1', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 2, + 'theIndex2', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 3, + 'theIndex3', + queueSettingsArg + ); + + // It returned the right results + expect(resp.status).toEqual(200); + const data = resp.payload; + expect(data).toEqual({ + errors: [], + enqueued: [ + { indexName: 'theIndex1' }, + { indexName: 'theIndex2' }, + { indexName: 'theIndex3' }, + ], + }); + }); + + it('gracefully handles partial successes', async () => { + mockReindexService.createReindexOperation + .mockResolvedValueOnce({ + attributes: { indexName: 'theIndex1' }, + }) + .mockRejectedValueOnce(new Error('oops!')); + + mockReindexService.hasRequiredPrivileges + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const resp = await routeDependencies.router.getHandler({ + method: 'post', + pathPattern: '/api/upgrade_assistant/reindex/batch', + })( + routeHandlerContextMock, + createRequestMock({ body: { indexNames: ['theIndex1', 'theIndex2', 'theIndex3'] } }), + kibanaResponseFactory + ); + + // It called create correctly + expect(mockReindexService.createReindexOperation).toHaveBeenCalledTimes(2); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 1, + 'theIndex1', + queueSettingsArg + ); + expect(mockReindexService.createReindexOperation).toHaveBeenNthCalledWith( + 2, + 'theIndex3', + queueSettingsArg + ); + + // It returned the right results + expect(resp.status).toEqual(200); + const data = resp.payload; + expect(data).toEqual({ + errors: [ + { + indexName: 'theIndex2', + message: 'You do not have adequate privileges to reindex "theIndex2".', + }, + { indexName: 'theIndex3', message: 'oops!' }, + ], + enqueued: [{ indexName: 'theIndex1' }], + }); + }); + }); + describe('POST /api/upgrade_assistant/reindex/{indexName}/cancel', () => { it('returns a 501', async () => { mockReindexService.cancelReindexing.mockResolvedValueOnce({}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts similarity index 58% rename from x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts rename to x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts index 72c2f2c29b72ea..697b73d8e10f68 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts @@ -3,31 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { schema } from '@kbn/config-schema'; import { - Logger, ElasticsearchServiceSetup, - SavedObjectsClient, kibanaResponseFactory, -} from '../../../../../src/core/server'; -import { ReindexStatus } from '../../common/types'; -import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; -import { reindexServiceFactory, ReindexWorker } from '../lib/reindexing'; -import { CredentialStore } from '../lib/reindexing/credential_store'; -import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; -import { RouteDependencies } from '../types'; -import { LicensingPluginSetup } from '../../../licensing/server'; -import { ReindexError } from '../lib/reindexing/error'; + Logger, + SavedObjectsClient, +} from '../../../../../../src/core/server'; + +import { LicensingPluginSetup } from '../../../../licensing/server'; + +import { ReindexStatus } from '../../../common/types'; + +import { versionCheckHandlerWrapper } from '../../lib/es_version_precheck'; +import { reindexServiceFactory, ReindexWorker } from '../../lib/reindexing'; +import { CredentialStore } from '../../lib/reindexing/credential_store'; +import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; +import { sortAndOrderReindexOperations } from '../../lib/reindexing/op_utils'; +import { ReindexError } from '../../lib/reindexing/error'; +import { RouteDependencies } from '../../types'; import { AccessForbidden, - IndexNotFound, CannotCreateIndex, + IndexNotFound, + MultipleReindexJobsFound, ReindexAlreadyInProgress, + ReindexCannotBeCancelled, ReindexTaskCannotBeDeleted, ReindexTaskFailed, - MultipleReindexJobsFound, -} from '../lib/reindexing/error_symbols'; +} from '../../lib/reindexing/error_symbols'; + +import { reindexHandler } from './reindex_handler'; +import { GetBatchQueueResponse, PostBatchResponse } from './types'; interface CreateReindexWorker { logger: Logger; @@ -63,6 +70,7 @@ const mapAnyErrorToKibanaHttpResponse = (e: any) => { return kibanaResponseFactory.customError({ body: e.message, statusCode: 422 }); case ReindexAlreadyInProgress: case MultipleReindexJobsFound: + case ReindexCannotBeCancelled: return kibanaResponseFactory.badRequest({ body: e.message }); default: // nothing matched @@ -91,46 +99,31 @@ export function registerReindexIndicesRoutes( async ( { core: { - savedObjects, + savedObjects: { client: savedObjectsClient }, elasticsearch: { dataClient }, }, }, request, response ) => { - const { indexName } = request.params as any; - const { client } = savedObjects; - const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); - const reindexActions = reindexActionsFactory(client, callAsCurrentUser); - const reindexService = reindexServiceFactory( - callAsCurrentUser, - reindexActions, - log, - licensing - ); - + const { indexName } = request.params; try { - if (!(await reindexService.hasRequiredPrivileges(indexName))) { - return response.forbidden({ - body: `You do not have adequate privileges to reindex this index.`, - }); - } - - const existingOp = await reindexService.findReindexOperation(indexName); - - // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. - const reindexOp = - existingOp && existingOp.attributes.status === ReindexStatus.paused - ? await reindexService.resumeReindexOperation(indexName) - : await reindexService.createReindexOperation(indexName); - - // Add users credentials for the worker to use - credentialStore.set(reindexOp, request.headers); + const result = await reindexHandler({ + savedObjects: savedObjectsClient, + dataClient, + indexName, + log, + licensing, + headers: request.headers, + credentialStore, + }); // Kick the worker on this node to immediately pickup the new reindex operation. getWorker().forceRefresh(); - return response.ok({ body: reindexOp.attributes }); + return response.ok({ + body: result, + }); } catch (e) { return mapAnyErrorToKibanaHttpResponse(e); } @@ -138,6 +131,97 @@ export function registerReindexIndicesRoutes( ) ); + // Get the current batch queue + router.get( + { + path: `${BASE_PATH}/batch/queue`, + validate: {}, + }, + async ( + { + core: { + elasticsearch: { dataClient }, + savedObjects, + }, + }, + request, + response + ) => { + const { client } = savedObjects; + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(client, callAsCurrentUser); + try { + const inProgressOps = await reindexActions.findAllByStatus(ReindexStatus.inProgress); + const { queue } = sortAndOrderReindexOperations(inProgressOps); + const result: GetBatchQueueResponse = { + queue: queue.map(savedObject => savedObject.attributes), + }; + return response.ok({ + body: result, + }); + } catch (e) { + return mapAnyErrorToKibanaHttpResponse(e); + } + } + ); + + // Add indices for reindexing to the worker's batch + router.post( + { + path: `${BASE_PATH}/batch`, + validate: { + body: schema.object({ + indexNames: schema.arrayOf(schema.string()), + }), + }, + }, + versionCheckHandlerWrapper( + async ( + { + core: { + savedObjects: { client: savedObjectsClient }, + elasticsearch: { dataClient }, + }, + }, + request, + response + ) => { + const { indexNames } = request.body; + const results: PostBatchResponse = { + enqueued: [], + errors: [], + }; + for (const indexName of indexNames) { + try { + const result = await reindexHandler({ + savedObjects: savedObjectsClient, + dataClient, + indexName, + log, + licensing, + headers: request.headers, + credentialStore, + enqueue: true, + }); + results.enqueued.push(result); + } catch (e) { + results.errors.push({ + indexName, + message: e.message, + }); + } + } + + if (results.errors.length < indexNames.length) { + // Kick the worker on this node to immediately pickup the batch. + getWorker().forceRefresh(); + } + + return response.ok({ body: results }); + } + ) + ); + // Get status router.get( { @@ -160,7 +244,7 @@ export function registerReindexIndicesRoutes( response ) => { const { client } = savedObjects; - const { indexName } = request.params as any; + const { indexName } = request.params; const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); const reindexActions = reindexActionsFactory(client, callAsCurrentUser); const reindexService = reindexServiceFactory( @@ -215,7 +299,7 @@ export function registerReindexIndicesRoutes( request, response ) => { - const { indexName } = request.params as any; + const { indexName } = request.params; const { client } = savedObjects; const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); const reindexActions = reindexActionsFactory(client, callAsCurrentUser); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.ts new file mode 100644 index 00000000000000..251450a9e37f29 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { ReindexOperation } from '../../../common/types'; + +// These types represent contracts from the reindex RESTful API endpoints and +// should be changed in a way that respects backwards compatibility. + +export interface PostBatchResponse { + enqueued: ReindexOperation[]; + errors: Array<{ indexName: string; message: string }>; +} + +export interface GetBatchQueueResponse { + queue: ReindexOperation[]; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts index aacf0b8f87ed01..1aa0f8e2c9f169 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts @@ -111,7 +111,7 @@ export default function indexTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type config: [index]: types that failed validation:\n- [index.0]: expected value of type [string] but got [number]\n- [index.1]: expected value to equal [null] but got [666]', + 'error validating action type config: [index]: types that failed validation:\n- [index.0]: expected value of type [string] but got [number]\n- [index.1]: expected value to equal [null]', }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts index c0f56c55ba8503..50cc80011777eb 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts @@ -18,7 +18,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration security and spaces enabled', function() { - this.tags('ciGroup3'); + this.tags('ciGroup5'); before(async () => { for (const space of Spaces) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts index b118a48fd642c1..10397a571b0ef4 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts @@ -16,7 +16,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration spaces only', function() { - this.tags('ciGroup3'); + this.tags('ciGroup9'); before(async () => { for (const space of Object.values(Spaces)) { diff --git a/x-pack/test/api_integration/apis/endpoint/alerts.ts b/x-pack/test/api_integration/apis/endpoint/alerts.ts index 45ae2b5438e181..1890b6f5d557d8 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts.ts @@ -70,7 +70,7 @@ export default function({ getService }: FtrProviderContext) { .get('/api/endpoint/alerts?page_size=0') .set('kbn-xsrf', 'xxx') .expect(400); - expect(body.message).to.contain('Value is [0] but it must be equal to or greater than [1]'); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); }); it('alerts api should return links to the next and previous pages using cursor-based pagination', async () => { diff --git a/x-pack/test/api_integration/apis/endpoint/metadata.ts b/x-pack/test/api_integration/apis/endpoint/metadata.ts index 4b0cc8d93a395d..516891d84dc919 100644 --- a/x-pack/test/api_integration/apis/endpoint/metadata.ts +++ b/x-pack/test/api_integration/apis/endpoint/metadata.ts @@ -100,7 +100,7 @@ export default function({ getService }: FtrProviderContext) { ], }) .expect(400); - expect(body.message).to.contain('Value is [0] but it must be equal to or greater than [1]'); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); }); it('metadata api should return page based on filters passed.', async () => { diff --git a/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js b/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js index e1a435e000faed..cbf44e07ac40a7 100644 --- a/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js +++ b/x-pack/test/api_integration/apis/management/rollup/index_patterns_extensions.js @@ -78,7 +78,7 @@ export default function({ getService }) { uri = `${BASE_URI}?${stringify(params, { sort: false })}`; ({ body } = await supertest.get(uri).expect(400)); expect(body.message).to.contain( - '[request query.meta_fields]: could not parse array value from [stringValue]' + '[request query.meta_fields]: could not parse array value from json input' ); }); diff --git a/x-pack/test/functional/apps/machine_learning/anomaly_detection/date_nanos_job.ts b/x-pack/test/functional/apps/machine_learning/anomaly_detection/date_nanos_job.ts index 2a9824f46778da..6af27d558432da 100644 --- a/x-pack/test/functional/apps/machine_learning/anomaly_detection/date_nanos_job.ts +++ b/x-pack/test/functional/apps/machine_learning/anomaly_detection/date_nanos_job.ts @@ -165,7 +165,8 @@ export default function({ getService }: FtrProviderContext) { }, ]; - describe('job on data set with date_nanos time field', function() { + // test failures, see #59419 + describe.skip('job on data set with date_nanos time field', function() { this.tags(['smoke', 'mlqa']); before(async () => { await esArchiver.load('ml/event_rate_nanos'); diff --git a/x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job.ts b/x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job.ts index a13cf3d61128e7..66b2f00009b187 100644 --- a/x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job.ts +++ b/x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job.ts @@ -271,7 +271,8 @@ export default function({ getService }: FtrProviderContext) { }, ]; - describe('saved search', function() { + // test failures, see #59354 + describe.skip('saved search', function() { this.tags(['smoke', 'mlqa']); before(async () => { await esArchiver.load('ml/farequote'); diff --git a/x-pack/test/functional/apps/spaces/enter_space.ts b/x-pack/test/functional/apps/spaces/enter_space.ts index b931a5cb0ca63d..e0b1ec544d4602 100644 --- a/x-pack/test/functional/apps/spaces/enter_space.ts +++ b/x-pack/test/functional/apps/spaces/enter_space.ts @@ -15,7 +15,10 @@ export default function enterSpaceFunctonalTests({ describe('Enter Space', function() { this.tags('smoke'); - before(async () => await esArchiver.load('spaces/enter_space')); + before(async () => { + await esArchiver.load('spaces/enter_space'); + await PageObjects.security.forceLogout(); + }); after(async () => await esArchiver.unload('spaces/enter_space')); afterEach(async () => { diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts index 5af9bc135ae27c..7b4a1e6e2b8a08 100644 --- a/x-pack/test/functional/apps/spaces/spaces_selection.ts +++ b/x-pack/test/functional/apps/spaces/spaces_selection.ts @@ -23,7 +23,10 @@ export default function spaceSelectorFunctonalTests({ describe('Spaces', function() { this.tags('smoke'); describe('Space Selector', () => { - before(async () => await esArchiver.load('spaces/selector')); + before(async () => { + await esArchiver.load('spaces/selector'); + await PageObjects.security.forceLogout(); + }); after(async () => await esArchiver.unload('spaces/selector')); afterEach(async () => { diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index f06dc0a14a383d..5f05fdb093d101 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -5,7 +5,7 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -import { TransformPivotConfig } from '../../../../legacy/plugins/transform/public/app/common'; +import { TransformPivotConfig } from '../../../../plugins/transform/public/app/common'; function getTransformConfig(): TransformPivotConfig { const date = Date.now(); diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz index dd8f719305bb91..0788e40326bb3a 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/mappings.json b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/mappings.json index 725a58af993252..fa5d6447762be9 100644 --- a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/mappings.json @@ -3,7 +3,7 @@ "value": { "aliases": { }, - "index": "my-index", + "index": "events-endpoint-1", "mappings": { "_meta": { "version": "1.5.0-dev" @@ -5262,4 +5262,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/monitoring_page.js b/x-pack/test/functional/page_objects/monitoring_page.js index 8de5b5e69d34d7..323c01e2348805 100644 --- a/x-pack/test/functional/page_objects/monitoring_page.js +++ b/x-pack/test/functional/page_objects/monitoring_page.js @@ -5,7 +5,7 @@ */ export function MonitoringPageProvider({ getPageObjects, getService }) { - const PageObjects = getPageObjects(['common', 'header', 'shield', 'spaceSelector']); + const PageObjects = getPageObjects(['common', 'header', 'security', 'shield', 'spaceSelector']); const testSubjects = getService('testSubjects'); const security = getService('security'); @@ -19,7 +19,7 @@ export function MonitoringPageProvider({ getPageObjects, getService }) { }); if (!useSuperUser) { - await PageObjects.common.navigateToApp('login'); + await PageObjects.security.forceLogout(); await PageObjects.shield.login('basic_monitoring_user', 'monitoring_user_password'); } await PageObjects.common.navigateToApp('monitoring'); diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js index 4803596b973bca..4b097b916573d4 100644 --- a/x-pack/test/functional/page_objects/security_page.js +++ b/x-pack/test/functional/page_objects/security_page.js @@ -110,12 +110,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } await userMenu.clickLogoutButton(); - - await retry.waitForWithTimeout( - 'login form', - config.get('timeouts.waitFor') * 5, - async () => await find.existsByDisplayedByCssSelector('.login-form') - ); + await this.waitForLoginForm(); } async forceLogout() { @@ -129,11 +124,17 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const url = PageObjects.common.getHostPort() + '/logout'; await browser.get(url); log.debug('Waiting on the login form to appear'); - await retry.waitForWithTimeout( - 'login form', - config.get('timeouts.waitFor') * 5, - async () => await find.existsByDisplayedByCssSelector('.login-form') - ); + await this.waitForLoginForm(); + } + + async waitForLoginForm() { + await retry.waitForWithTimeout('login form', config.get('timeouts.waitFor') * 5, async () => { + const alert = await browser.getAlert(); + if (alert && alert.accept) { + await alert.accept(); + } + return await find.existsByDisplayedByCssSelector('.login-form'); + }); } async clickRolesSection() { diff --git a/x-pack/test/functional/services/transform_ui/api.ts b/x-pack/test/functional/services/transform_ui/api.ts index 6a4a1dfff6ea16..94792d6590caac 100644 --- a/x-pack/test/functional/services/transform_ui/api.ts +++ b/x-pack/test/functional/services/transform_ui/api.ts @@ -7,11 +7,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { TRANSFORM_STATE } from '../../../../plugins/transform/common'; import { - TRANSFORM_STATE, TransformPivotConfig, TransformStats, -} from '../../../../legacy/plugins/transform/public/app/common'; +} from '../../../../plugins/transform/public/app/common'; export function TransformAPIProvider({ getService }: FtrProviderContext) { const es = getService('legacyEs'); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 25ebc6d610f869..75ae6b9ea7c211 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -60,7 +60,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('thresholdAlertTimeFieldSelect'); const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); await fieldOptions[1].click(); + // need this two out of popup clicks to close them await nameInput.click(); + await testSubjects.click('intervalInput'); + await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); const connectorNameInput = await testSubjects.find('nameInput'); diff --git a/x-pack/test/siem_cypress/config.ts b/x-pack/test/siem_cypress/config.ts index 05c1e471e74a94..261cfdc1c4429d 100644 --- a/x-pack/test/siem_cypress/config.ts +++ b/x-pack/test/siem_cypress/config.ts @@ -8,6 +8,8 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; + import { SiemCypressTestRunner } from './runner'; export default async function({ readConfigFile }: FtrConfigProviderContext) { @@ -32,6 +34,8 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs'), // define custom es server here + 'xpack.security.authc.api_key.enabled=true', + 'xpack.security.enabled=true', ], }, @@ -41,6 +45,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), '--csp.strict=false', // define custom kibana server args here + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, ], }, }; diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js index 38fc1f0c6356f7..a99c02ffef23e7 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { ReindexStatus, REINDEX_OP_TYPE } from '../../../plugins/upgrade_assistant/common/types'; +import { generateNewIndexName } from '../../../plugins/upgrade_assistant/server/lib/reindexing/index_settings'; export default function({ getService }) { const supertest = getService('supertest'); @@ -134,5 +135,73 @@ export default function({ getService }) { expect(lastState.errorMessage).to.equal(null); expect(lastState.status).to.equal(ReindexStatus.completed); }); + + it('should reindex a batch in order and report queue state', async () => { + const assertQueueState = async (firstInQueueIndexName, queueLength) => { + const response = await supertest + .get(`/api/upgrade_assistant/reindex/batch/queue`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { queue } = response.body; + + const [firstInQueue] = queue; + + if (!firstInQueueIndexName) { + expect(firstInQueueIndexName).to.be(undefined); + } else { + expect(firstInQueue.indexName).to.be(firstInQueueIndexName); + } + + expect(queue.length).to.be(queueLength); + }; + + const test1 = 'batch-reindex-test1'; + const test2 = 'batch-reindex-test2'; + const test3 = 'batch-reindex-test3'; + + const cleanupReindex = async indexName => { + try { + await es.indices.delete({ index: generateNewIndexName(indexName) }); + } catch (e) { + try { + await es.indices.delete({ index: indexName }); + } catch (e) { + // Ignore + } + } + }; + + try { + // Set up indices for the batch + await es.indices.create({ index: test1 }); + await es.indices.create({ index: test2 }); + await es.indices.create({ index: test3 }); + + const result = await supertest + .post(`/api/upgrade_assistant/reindex/batch`) + .set('kbn-xsrf', 'xxx') + .send({ indexNames: [test1, test2, test3] }) + .expect(200); + + expect(result.body.enqueued.length).to.equal(3); + expect(result.body.errors.length).to.equal(0); + + await assertQueueState(test1, 3); + await waitForReindexToComplete(test1); + + await assertQueueState(test2, 2); + await waitForReindexToComplete(test2); + + await assertQueueState(test3, 1); + await waitForReindexToComplete(test3); + + await assertQueueState(undefined, 0); + } finally { + await cleanupReindex(test1); + await cleanupReindex(test2); + await cleanupReindex(test3); + } + }); }); } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 31ef0bef18a85e..a6c94ff74620ed 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -43,4 +43,4 @@ "jest" ] } -} \ No newline at end of file +}