diff --git a/.eslintrc.js b/.eslintrc.js index 56c06902e062b..f1e0b7d9353e8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -112,7 +112,6 @@ module.exports = { files: ['x-pack/plugins/lens/**/*.{js,ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', }, }, { diff --git a/.i18nrc.json b/.i18nrc.json index 3b2e628f7226f..034b9da799d3e 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -34,7 +34,7 @@ "kibana_utils": "src/plugins/kibana_utils", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", - "regionMap": "src/legacy/core_plugins/region_map", + "regionMap": "src/plugins/region_map", "savedObjects": "src/plugins/saved_objects", "savedObjectsManagement": "src/plugins/saved_objects_management", "server": "src/legacy/server", diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 8442f1ecc6411..fd496da26283c 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -114,7 +114,9 @@ export class ApplicationService { context, http: { basePath }, injectedMetadata, - redirectTo = (path: string) => (window.location.href = path), + redirectTo = (path: string) => { + window.location.assign(path); + }, history, }: SetupDeps): InternalApplicationSetup { const basename = basePath.get(); @@ -210,7 +212,10 @@ export class ApplicationService { } const appBasePath = basePath.prepend(appRoute); - const mount: LegacyAppMounter = () => redirectTo(appBasePath); + const mount: LegacyAppMounter = ({ history: appHistory }) => { + redirectTo(appHistory.createHref(appHistory.location)); + window.location.reload(); + }; const { updater$, ...appProps } = app; this.apps.set(app.id, { diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 60c36d3e330e0..e399fbc726977 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -18,8 +18,10 @@ */ import { take } from 'rxjs/operators'; -import { createRenderer } from './utils'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory, MemoryHistory } from 'history'; + +import { createRenderer } from './utils'; import { ApplicationService } from '../application_service'; import { httpServiceMock } from '../../http/http_service.mock'; import { contextServiceMock } from '../../context/context_service.mock'; @@ -27,6 +29,9 @@ import { injectedMetadataServiceMock } from '../../injected_metadata/injected_me import { MockLifecycle } from '../test_types'; import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; +import { ScopedHistory } from '../scoped_history'; + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); describe('ApplicationService', () => { let setupDeps: MockLifecycle<'setup'>; @@ -83,7 +88,10 @@ describe('ApplicationService', () => { expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); - resolveMount!(); + await act(async () => { + resolveMount!(); + await flushPromises(); + }); expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); }); @@ -109,7 +117,7 @@ describe('ApplicationService', () => { const { navigateToApp, currentAppId$ } = await service.start(startDeps); - await navigateToApp('app1'); + await act(() => navigateToApp('app1')); expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); @@ -120,6 +128,46 @@ describe('ApplicationService', () => { }); }); + it('redirects to full path when navigating to legacy app', async () => { + const redirectTo = jest.fn(); + const reloadSpy = jest.spyOn(window.location, 'reload').mockImplementation(() => {}); + + // In the real application, we use a BrowserHistory instance configured with `basename`. However, in tests we must + // use MemoryHistory which does not support `basename`. In order to emulate this behavior, we will wrap this + // instance with a ScopedHistory configured with a basepath. + history.push(setupDeps.http.basePath.get()); // ScopedHistory constructor will fail if underlying history is not currently at basePath. + const { register, registerLegacyApp } = service.setup({ + ...setupDeps, + redirectTo, + history: new ScopedHistory(history, setupDeps.http.basePath.get()), + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave(actions => actions.default()); + return () => undefined; + }, + }); + registerLegacyApp({ + id: 'myLegacyTestApp', + appUrl: '/app/myLegacyTestApp', + title: 'My Legacy Test App', + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await navigate('/test/app/app1'); + await act(() => navigateToApp('myLegacyTestApp', { path: '#/some-path' })); + + expect(redirectTo).toHaveBeenCalledWith('/test/app/myLegacyTestApp#/some-path'); + expect(reloadSpy).toHaveBeenCalled(); + reloadSpy.mockRestore(); + }); + describe('leaving an application that registered an app leave handler', () => { it('navigates to the new app if action is default', async () => { startDeps.overlays.openConfirm.mockResolvedValue(true); @@ -146,8 +194,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).not.toHaveBeenCalled(); expect(history.entries.length).toEqual(3); @@ -179,8 +229,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( @@ -216,8 +268,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( diff --git a/src/legacy/core_plugins/region_map/index.ts b/src/legacy/core_plugins/region_map/index.ts deleted file mode 100644 index 8c059314786bc..0000000000000 --- a/src/legacy/core_plugins/region_map/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; - -const regionMapPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'region_map', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars(server) { - const { regionmap } = server.config().get('map'); - - return { - regionmap, - }; - }, - }, - init: (server: Legacy.Server) => ({}), - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default regionMapPluginInitializer; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index e52cb34aa0ab0..ee56ad60441f4 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1809,20 +1809,20 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/maps_legacy/config.ts b/src/plugins/maps_legacy/config.ts index 13a0ad6b393a3..67e46d2270583 100644 --- a/src/plugins/maps_legacy/config.ts +++ b/src/plugins/maps_legacy/config.ts @@ -19,31 +19,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { configSchema as tilemapSchema } from '../tile_map/config'; - -// TODO: Pull this portion from region_map -export const regionmapSchema = schema.object({ - includeElasticMapsService: schema.boolean({ defaultValue: true }), - layers: schema.arrayOf( - schema.object({ - url: schema.string(), - format: schema.object({ - type: schema.string({ defaultValue: 'geojson' }), - }), - meta: schema.object({ - feature_collection_path: schema.string({ defaultValue: 'data' }), - }), - attribution: schema.string(), - name: schema.string(), - fields: schema.arrayOf( - schema.object({ - name: schema.string(), - description: schema.string(), - }) - ), - }), - { defaultValue: [] } - ), -}); +import { configSchema as regionmapSchema } from '../region_map/config'; export const configSchema = schema.object({ includeElasticMapsService: schema.boolean({ defaultValue: true }), diff --git a/src/plugins/maps_legacy/public/index.ts b/src/plugins/maps_legacy/public/index.ts index 3fe377fbdc41f..a7f5427909334 100644 --- a/src/plugins/maps_legacy/public/index.ts +++ b/src/plugins/maps_legacy/public/index.ts @@ -45,6 +45,7 @@ import { import { mapTooltipProvider } from './tooltip_provider'; export interface MapsLegacyConfigType { + regionmap: any; emsTileLayerId: string; includeElasticMapsService: boolean; proxyElasticMapsServiceInMaps: boolean; diff --git a/src/plugins/region_map/config.ts b/src/plugins/region_map/config.ts new file mode 100644 index 0000000000000..a721a76ca0a82 --- /dev/null +++ b/src/plugins/region_map/config.ts @@ -0,0 +1,46 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + includeElasticMapsService: schema.boolean({ defaultValue: true }), + layers: schema.arrayOf( + schema.object({ + url: schema.string(), + format: schema.object({ + type: schema.string({ defaultValue: 'geojson' }), + }), + meta: schema.object({ + feature_collection_path: schema.string({ defaultValue: 'data' }), + }), + attribution: schema.string(), + name: schema.string(), + fields: schema.arrayOf( + schema.object({ + name: schema.string(), + description: schema.string(), + }) + ), + }), + { defaultValue: [] } + ), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json new file mode 100644 index 0000000000000..3a6f64e92bcba --- /dev/null +++ b/src/plugins/region_map/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "regionMap", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["map", "regionmap"], + "ui": true, + "server": true, + "requiredPlugins": [ + "visualizations", + "expressions", + "mapsLegacy", + "data" + ] +} diff --git a/src/legacy/core_plugins/region_map/package.json b/src/plugins/region_map/package.json similarity index 100% rename from src/legacy/core_plugins/region_map/package.json rename to src/plugins/region_map/package.json diff --git a/src/legacy/core_plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap b/src/plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap similarity index 100% rename from src/legacy/core_plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap rename to src/plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap diff --git a/src/legacy/core_plugins/region_map/public/__tests__/aftercolorchange.png b/src/plugins/region_map/public/__tests__/aftercolorchange.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/aftercolorchange.png rename to src/plugins/region_map/public/__tests__/aftercolorchange.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterdatachange.png b/src/plugins/region_map/public/__tests__/afterdatachange.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterdatachange.png rename to src/plugins/region_map/public/__tests__/afterdatachange.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterdatachangeandresize.png b/src/plugins/region_map/public/__tests__/afterdatachangeandresize.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterdatachangeandresize.png rename to src/plugins/region_map/public/__tests__/afterdatachangeandresize.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterresize.png b/src/plugins/region_map/public/__tests__/afterresize.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterresize.png rename to src/plugins/region_map/public/__tests__/afterresize.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/changestartup.png b/src/plugins/region_map/public/__tests__/changestartup.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/changestartup.png rename to src/plugins/region_map/public/__tests__/changestartup.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/initial.png b/src/plugins/region_map/public/__tests__/initial.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/initial.png rename to src/plugins/region_map/public/__tests__/initial.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/plugins/region_map/public/__tests__/region_map_visualization.js similarity index 92% rename from src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js rename to src/plugins/region_map/public/__tests__/region_map_visualization.js index 7271f39debb39..cefef98fae814 100644 --- a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/plugins/region_map/public/__tests__/region_map_visualization.js @@ -25,17 +25,17 @@ import ChoroplethLayer from '../choropleth_layer'; import { ImageComparator } from 'test_utils/image_comparator'; import worldJson from './world.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_CATALOGUE from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; +import EMS_CATALOGUE from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_FILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; +import EMS_FILES from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_TILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; +import EMS_TILES from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; +import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; +import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_DARK_MAP from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; +import EMS_STYLE_DARK_MAP from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; import initialPng from './initial.png'; import toiso3Png from './toiso3.png'; @@ -48,14 +48,14 @@ import changestartupPng from './changestartup.png'; import { createRegionMapVisualization } from '../region_map_visualization'; import { createRegionMapTypeDefinition } from '../region_map_type'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ExprVis } from '../../../../../plugins/visualizations/public/expressions/vis'; +import { ExprVis } from '../../../visualizations/public/expressions/vis'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { BaseVisType } from '../../../../../plugins/visualizations/public/vis_types/base_vis_type'; +import { BaseVisType } from '../../../visualizations/public/vis_types/base_vis_type'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { setInjectedVarFunc } from '../../../../../plugins/maps_legacy/public/kibana_services'; +import { setInjectedVarFunc } from '../../../maps_legacy/public/kibana_services'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceSettings } from '../../../../../plugins/maps_legacy/public/map/service_settings'; -import { getBaseMapsVis } from '../../../../../plugins/maps_legacy/public'; +import { ServiceSettings } from '../../../maps_legacy/public/map/service_settings'; +import { getBaseMapsVis } from '../../../maps_legacy/public'; const THRESHOLD = 0.45; const PIXEL_DIFF = 96; diff --git a/src/legacy/core_plugins/region_map/public/__tests__/toiso3.png b/src/plugins/region_map/public/__tests__/toiso3.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/toiso3.png rename to src/plugins/region_map/public/__tests__/toiso3.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/world.json b/src/plugins/region_map/public/__tests__/world.json similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/world.json rename to src/plugins/region_map/public/__tests__/world.json diff --git a/src/legacy/core_plugins/region_map/public/choropleth_layer.js b/src/plugins/region_map/public/choropleth_layer.js similarity index 98% rename from src/legacy/core_plugins/region_map/public/choropleth_layer.js rename to src/plugins/region_map/public/choropleth_layer.js index f8c48958a1b9b..ddaf2db257fba 100644 --- a/src/legacy/core_plugins/region_map/public/choropleth_layer.js +++ b/src/plugins/region_map/public/choropleth_layer.js @@ -22,9 +22,9 @@ import _ from 'lodash'; import d3 from 'd3'; import { i18n } from '@kbn/i18n'; import * as topojson from 'topojson-client'; -import { toastNotifications } from 'ui/notify'; -import { colorUtil, KibanaMapLayer } from '../../../../plugins/maps_legacy/public'; -import { truncatedColorMaps } from '../../../../plugins/charts/public'; +import { getNotifications } from './kibana_services'; +import { colorUtil, KibanaMapLayer } from '../../maps_legacy/public'; +import { truncatedColorMaps } from '../../charts/public'; const EMPTY_STYLE = { weight: 1, @@ -182,7 +182,7 @@ CORS configuration of the server permits requests from the Kibana application on ); } - toastNotifications.addDanger({ + getNotifications().toasts.addDanger({ title: i18n.translate( 'regionMap.choroplethLayer.downloadingVectorDataErrorMessageTitle', { diff --git a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx b/src/plugins/region_map/public/components/region_map_options.tsx similarity index 95% rename from src/legacy/core_plugins/region_map/public/components/region_map_options.tsx rename to src/plugins/region_map/public/components/region_map_options.tsx index 5604067433f13..9a6987b981539 100644 --- a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx +++ b/src/plugins/region_map/public/components/region_map_options.tsx @@ -22,17 +22,9 @@ import { EuiIcon, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elast import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { - FileLayerField, - VectorLayer, - IServiceSettings, -} from '../../../../../plugins/maps_legacy/public'; -import { - NumberInputOption, - SelectOption, - SwitchOption, -} from '../../../../../plugins/charts/public'; -import { RegionMapVisParams, WmsOptions } from '../../../../../plugins/maps_legacy/public'; +import { FileLayerField, VectorLayer, IServiceSettings } from '../../../maps_legacy/public'; +import { NumberInputOption, SelectOption, SwitchOption } from '../../../charts/public'; +import { RegionMapVisParams, WmsOptions } from '../../../maps_legacy/public'; const mapLayerForOption = ({ layerId, name }: VectorLayer) => ({ text: name, diff --git a/src/legacy/core_plugins/region_map/public/index.ts b/src/plugins/region_map/public/index.ts similarity index 86% rename from src/legacy/core_plugins/region_map/public/index.ts rename to src/plugins/region_map/public/index.ts index a29f5aa247026..3f920ad16683a 100644 --- a/src/legacy/core_plugins/region_map/public/index.ts +++ b/src/plugins/region_map/public/index.ts @@ -17,9 +17,14 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../core/public'; +import { PluginInitializerContext } from 'kibana/public'; import { RegionMapPlugin as Plugin } from './plugin'; +export interface RegionMapsConfigType { + includeElasticMapsService: boolean; + layers: any[]; +} + export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } diff --git a/src/plugins/region_map/public/kibana_services.ts b/src/plugins/region_map/public/kibana_services.ts new file mode 100644 index 0000000000000..1ef58c69c5bef --- /dev/null +++ b/src/plugins/region_map/public/kibana_services.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 { NotificationsStart } from 'kibana/public'; +import { createGetterSetter } from '../../kibana_utils/public'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getFormatService, setFormatService] = createGetterSetter< + DataPublicPluginStart['fieldFormats'] +>('data.fieldFormats'); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); diff --git a/src/legacy/core_plugins/region_map/public/plugin.ts b/src/plugins/region_map/public/plugin.ts similarity index 56% rename from src/legacy/core_plugins/region_map/public/plugin.ts rename to src/plugins/region_map/public/plugin.ts index 08a73517dc13b..09a13fbe9774e 100644 --- a/src/legacy/core_plugins/region_map/public/plugin.ts +++ b/src/plugins/region_map/public/plugin.ts @@ -22,18 +22,19 @@ import { Plugin, PluginInitializerContext, IUiSettingsClient, -} from '../../../../core/public'; -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; + NotificationsStart, +} from 'kibana/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; // @ts-ignore import { createRegionMapFn } from './region_map_fn'; // @ts-ignore import { createRegionMapTypeDefinition } from './region_map_type'; -import { - getBaseMapsVis, - IServiceSettings, - MapsLegacyPluginSetup, -} from '../../../../plugins/maps_legacy/public'; +import { getBaseMapsVis, IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; +import { setFormatService, setNotifications } from './kibana_services'; +import { DataPublicPluginStart } from '../../data/public'; +import { RegionMapsConfigType } from './index'; +import { ConfigSchema } from '../../maps_legacy/config'; /** @private */ interface RegionMapVisualizationDependencies { @@ -50,27 +51,46 @@ export interface RegionMapPluginSetupDependencies { mapsLegacy: MapsLegacyPluginSetup; } +/** @internal */ +export interface RegionMapPluginStartDependencies { + data: DataPublicPluginStart; + notifications: NotificationsStart; +} + /** @internal */ export interface RegionMapsConfig { includeElasticMapsService: boolean; layers: any[]; } +export interface RegionMapPluginSetup { + config: any; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RegionMapPluginStart {} + /** @internal */ -export class RegionMapPlugin implements Plugin, void> { - initializerContext: PluginInitializerContext; +export class RegionMapPlugin implements Plugin { + readonly _initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; + this._initializerContext = initializerContext; } public async setup( core: CoreSetup, { expressions, visualizations, mapsLegacy }: RegionMapPluginSetupDependencies ) { + const config = { + ...this._initializerContext.config.get(), + // The maps legacy plugin updates the regionmap config directly in service_settings, + // future work on how configurations across the different plugins are organized would + // ideally constrain regionmap config updates to occur only from this plugin + ...mapsLegacy.config.regionmap, + }; const visualizationDependencies: Readonly = { uiSettings: core.uiSettings, - regionmapsConfig: core.injectedMetadata.getInjectedVar('regionmap') as RegionMapsConfig, + regionmapsConfig: config as RegionMapsConfig, serviceSettings: mapsLegacy.serviceSettings, BaseMapsVisualization: getBaseMapsVis(core, mapsLegacy.serviceSettings), }; @@ -80,9 +100,15 @@ export class RegionMapPlugin implements Plugin, void> { visualizations.createBaseVisualization( createRegionMapTypeDefinition(visualizationDependencies) ); + + return { + config, + }; } - public start(core: CoreStart) { - // nothing to do here yet + // @ts-ignore + public start(core: CoreStart, { data }: RegionMapPluginStartDependencies) { + setFormatService(data.fieldFormats); + setNotifications(core.notifications); } } diff --git a/src/legacy/core_plugins/region_map/public/region_map_fn.js b/src/plugins/region_map/public/region_map_fn.js similarity index 100% rename from src/legacy/core_plugins/region_map/public/region_map_fn.js rename to src/plugins/region_map/public/region_map_fn.js diff --git a/src/legacy/core_plugins/region_map/public/region_map_fn.test.js b/src/plugins/region_map/public/region_map_fn.test.js similarity index 92% rename from src/legacy/core_plugins/region_map/public/region_map_fn.test.js rename to src/plugins/region_map/public/region_map_fn.test.js index 07b4e33b85e27..684cc5e897df4 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_fn.test.js +++ b/src/plugins/region_map/public/region_map_fn.test.js @@ -18,11 +18,9 @@ */ // eslint-disable-next-line -import { functionWrapper } from '../../../../plugins/expressions/common/expression_functions/specs/tests/utils'; +import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; import { createRegionMapFn } from './region_map_fn'; -jest.mock('ui/new_platform'); - describe('interpreter/functions#regionmap', () => { const fn = functionWrapper(createRegionMapFn()); const context = { diff --git a/src/legacy/core_plugins/region_map/public/region_map_type.js b/src/plugins/region_map/public/region_map_type.js similarity index 95% rename from src/legacy/core_plugins/region_map/public/region_map_type.js rename to src/plugins/region_map/public/region_map_type.js index b7ed14ed3706e..d29360a9589ab 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_type.js +++ b/src/plugins/region_map/public/region_map_type.js @@ -21,9 +21,9 @@ import { i18n } from '@kbn/i18n'; import { mapToLayerWithId } from './util'; import { createRegionMapVisualization } from './region_map_visualization'; import { RegionMapOptions } from './components/region_map_options'; -import { truncatedColorSchemas } from '../../../../plugins/charts/public'; -import { Schemas } from '../../../../plugins/vis_default_editor/public'; -import { ORIGIN } from '../../../../plugins/maps_legacy/public'; +import { truncatedColorSchemas } from '../../charts/public'; +import { Schemas } from '../../vis_default_editor/public'; +import { ORIGIN } from '../../maps_legacy/public'; export function createRegionMapTypeDefinition(dependencies) { const { uiSettings, regionmapsConfig, serviceSettings } = dependencies; diff --git a/src/legacy/core_plugins/region_map/public/region_map_visualization.js b/src/plugins/region_map/public/region_map_visualization.js similarity index 93% rename from src/legacy/core_plugins/region_map/public/region_map_visualization.js rename to src/plugins/region_map/public/region_map_visualization.js index 5dbc1ecad277f..ed6a3ed2c10c8 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_visualization.js +++ b/src/plugins/region_map/public/region_map_visualization.js @@ -19,11 +19,10 @@ import { i18n } from '@kbn/i18n'; import ChoroplethLayer from './choropleth_layer'; -import { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; -import { toastNotifications } from 'ui/notify'; -import { truncatedColorMaps } from '../../../../plugins/charts/public'; +import { getFormatService, getNotifications } from './kibana_services'; +import { truncatedColorMaps } from '../../charts/public'; import { tooltipFormatter } from './tooltip_formatter'; -import { mapTooltipProvider } from '../../../../plugins/maps_legacy/public'; +import { mapTooltipProvider } from '../../maps_legacy/public'; export function createRegionMapVisualization({ serviceSettings, @@ -75,7 +74,7 @@ export function createRegionMapVisualization({ results ); - const metricFieldFormatter = getFormat(this._params.metric.format); + const metricFieldFormatter = getFormatService().deserialize(this._params.metric.format); this._choroplethLayer.setMetrics(results, metricFieldFormatter, valueColumn.name); if (termColumn && valueColumn) { @@ -108,7 +107,7 @@ export function createRegionMapVisualization({ this._params.showAllShapes ); - const metricFieldFormatter = getFormat(this._params.metric.format); + const metricFieldFormatter = getFormatService().deserialize(this._params.metric.format); this._choroplethLayer.setJoinField(visParams.selectedJoinField.name); this._choroplethLayer.setColorRamp(truncatedColorMaps[visParams.colorSchema].value); @@ -177,7 +176,7 @@ export function createRegionMapVisualization({ const shouldShowWarning = this._params.isDisplayWarning && uiSettings.get('visualization:regionmap:showWarnings'); if (event.mismatches.length > 0 && shouldShowWarning) { - toastNotifications.addWarning({ + getNotifications().toasts.addWarning({ title: i18n.translate('regionMap.visualization.unableToShowMismatchesWarningTitle', { defaultMessage: 'Unable to show {mismatchesLength} {oneMismatch, plural, one {result} other {results}} on map', diff --git a/src/legacy/core_plugins/region_map/public/tooltip_formatter.js b/src/plugins/region_map/public/tooltip_formatter.js similarity index 100% rename from src/legacy/core_plugins/region_map/public/tooltip_formatter.js rename to src/plugins/region_map/public/tooltip_formatter.js diff --git a/src/legacy/core_plugins/region_map/public/util.ts b/src/plugins/region_map/public/util.ts similarity index 86% rename from src/legacy/core_plugins/region_map/public/util.ts rename to src/plugins/region_map/public/util.ts index b4e0dcd5f3510..0160a32e81522 100644 --- a/src/legacy/core_plugins/region_map/public/util.ts +++ b/src/plugins/region_map/public/util.ts @@ -17,8 +17,8 @@ * under the License. */ -import { FileLayer, VectorLayer } from '../../../../plugins/maps_legacy/public'; -import { ORIGIN } from '../../../../plugins/maps_legacy/public'; +import { FileLayer, VectorLayer } from '../../maps_legacy/public'; +import { ORIGIN } from '../../maps_legacy/public'; export const mapToLayerWithId = (prefix: string, layer: FileLayer): VectorLayer => ({ ...layer, diff --git a/src/legacy/core_plugins/region_map/public/legacy.ts b/src/plugins/region_map/server/index.ts similarity index 57% rename from src/legacy/core_plugins/region_map/public/legacy.ts rename to src/plugins/region_map/server/index.ts index 4bbd839331e56..e2c544d2d0ba6 100644 --- a/src/legacy/core_plugins/region_map/public/legacy.ts +++ b/src/plugins/region_map/server/index.ts @@ -17,19 +17,18 @@ * under the License. */ -import { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; +import { PluginConfigDescriptor } from 'kibana/server'; +import { configSchema, ConfigSchema } from '../config'; -import { RegionMapPluginSetupDependencies } from './plugin'; -import { plugin } from '.'; - -const plugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, - mapsLegacy: npSetup.plugins.mapsLegacy, +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + includeElasticMapsService: true, + layers: true, + }, + schema: configSchema, }; -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, plugins); -export const start = pluginInstance.start(npStart.core); +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/test/common/services/elasticsearch.ts b/test/common/services/elasticsearch.ts index 63c4bfeeb4ce7..0436dc901292d 100644 --- a/test/common/services/elasticsearch.ts +++ b/test/common/services/elasticsearch.ts @@ -18,8 +18,9 @@ */ import { format as formatUrl } from 'url'; - +import fs from 'fs'; import { Client } from '@elastic/elasticsearch'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -27,6 +28,9 @@ export function ElasticsearchProvider({ getService }: FtrProviderContext) { const config = getService('config'); return new Client({ + ssl: { + ca: fs.readFileSync(CA_CERT_PATH, 'utf-8'), + }, nodes: [formatUrl(config.get('servers.elasticsearch'))], requestTimeout: config.get('timeouts.esRequestTimeout'), }); diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index 20e69ef8345c6..0f63510dce431 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -35,7 +35,7 @@ export default function({ getService, getPageObjects }) { describe('discover histogram', function describeIndexTests() { before(async function() { log.debug('load kibana index with default index pattern'); - await PageObjects.common.navigateToApp('home'); + await PageObjects.common.navigateToApp('settings'); await security.testUser.setRoles([ 'kibana_admin', 'test_logstash_reader', diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index 3a3d6b93e166b..b0a572d9a54f9 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -58,6 +58,7 @@ export default function({ getService, getPageObjects }) { }); it('should create shakespeare index pattern', async function() { + await PageObjects.common.navigateToApp('settings'); log.debug('Create shakespeare index pattern'); await PageObjects.settings.createIndexPattern('shakespeare', null); const patternName = await PageObjects.settings.getIndexPageHeading(); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 93debdcc37f0a..4a7570049ded7 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -111,7 +111,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo await browser.get(appUrl); } else { log.debug(`navigateToUrl ${appUrl}`); - await browser.get(appUrl); + await browser.get(appUrl, insertTimestamp); // accept alert if it pops up const alert = await browser.getAlert(); await alert?.accept(); @@ -242,7 +242,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo let lastUrl = await retry.try(async () => { // since we're using hash URLs, always reload first to force re-render log.debug('navigate to: ' + appUrl); - await browser.get(appUrl); + await browser.get(appUrl, insertTimestamp); // accept alert if it pops up const alert = await browser.getAlert(); await alert?.accept(); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b7a6e10efd7dc..8175361ffc842 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -324,7 +324,6 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider isStandardIndexPattern = true ) { await retry.try(async () => { - await this.navigateTo(); await PageObjects.header.waitUntilLoadingHasFinished(); await this.clickKibanaIndexPatterns(); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/test/interpreter_functional/test_suites/run_pipeline/basic.ts b/test/interpreter_functional/test_suites/run_pipeline/basic.ts index 51ad789143c54..a2172dd2da1ba 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/basic.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/basic.ts @@ -113,11 +113,10 @@ export default function({ await expectExpression('partial_test_2', metricExpr, context).toMatchSnapshot() ).toMatchScreenshot(); - // TODO: should be uncommented when the region map is migrated to the new platform - // const regionMapExpr = `regionmap visConfig='{"metric":{"accessor":1,"format":{"id":"number"}},"bucket":{"accessor":0}}'`; - // await ( - // await expectExpression('partial_test_3', regionMapExpr, context).toMatchSnapshot() - // ).toMatchScreenshot(); + const regionMapExpr = `regionmap visConfig='{"metric":{"accessor":1,"format":{"id":"number"}},"bucket":{"accessor":0}}'`; + await ( + await expectExpression('partial_test_3', regionMapExpr, context).toMatchSnapshot() + ).toMatchScreenshot(); }); }); }); diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 9e7aedcc90bb5..ff8add42a5085 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -560,7 +560,7 @@ export class EndpointDocGenerator { applied: { actions: { configure_elasticsearch_connection: { - message: 'elasticsearch comes configured successfully', + message: 'elasticsearch communications configured successfully', status: HostPolicyResponseActionStatus.success, }, configure_kernel: { diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 181b0e7ab3884..b39b2e89ee150 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -644,6 +644,9 @@ export interface HostPolicyResponseActions { read_malware_config: HostPolicyResponseActionDetails; } +/** + * policy configurations returned by the endpoint in response to a user applying a policy + */ export type HostPolicyResponseConfiguration = HostPolicyResponse['endpoint']['policy']['applied']['response']['configurations']; interface HostPolicyResponseConfigurationStatus { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx index aa04f2fdff57f..8714141364e7d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx @@ -12,7 +12,6 @@ import { HostPolicyResponseActions, HostPolicyResponseConfiguration, Immutable, - ImmutableArray, } from '../../../../../../common/types'; import { formatResponse } from './policy_response_friendly_names'; import { POLICY_STATUS_TO_HEALTH_COLOR } from './host_constants'; @@ -51,7 +50,7 @@ const ResponseActions = memo( actions, actionStatus, }: { - actions: ImmutableArray; + actions: Immutable>; actionStatus: Partial; }) => { return ( diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts index 251b3e86bc3f9..502aa66b24421 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts @@ -159,8 +159,7 @@ responseMap.set( ); /** - * Takes in the snake-cased response from the API and - * removes the underscores and capitalizes the string. + * Maps a server provided value to corresponding i18n'd string. */ export function formatResponse(responseString: string) { if (responseMap.has(responseString)) { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts new file mode 100644 index 0000000000000..3a2ee7ef8b008 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon, { SinonFakeServer } from 'sinon'; + +import { API_BASE_PATH } from '../../../common/constants'; + +// Register helpers to mock HTTP Requests +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const setLoadPipelinesResponse = (response?: any[], error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', API_BASE_PATH, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setLoadPipelineResponse = (response?: {}, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', `${API_BASE_PATH}/:name`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setDeletePipelineResponse = (response?: object) => { + server.respondWith('DELETE', `${API_BASE_PATH}/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + + const setCreatePipelineResponse = (response?: object, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('POST', API_BASE_PATH, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + return { + setLoadPipelinesResponse, + setLoadPipelineResponse, + setDeletePipelineResponse, + setCreatePipelineResponse, + }; +}; + +export const init = () => { + const server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Define default response for unhandled requests. + // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, + // and we can mock them all with a 200 instead of mocking each one individually. + server.respondWith([200, {}, 'DefaultMockedResponse']); + + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + + return { + server, + httpRequestsMockHelpers, + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts new file mode 100644 index 0000000000000..6216119c5d1d1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts @@ -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 { setup as pipelinesListSetup } from './pipelines_list.helpers'; +import { setup as pipelinesCreateSetup } from './pipelines_create.helpers'; +import { setup as pipelinesCloneSetup } from './pipelines_clone.helpers'; +import { setup as pipelinesEditSetup } from './pipelines_edit.helpers'; + +export { nextTick, getRandomString, findTestSubject } from '../../../../../test_utils'; + +export { setupEnvironment } from './setup_environment'; + +export const pageHelpers = { + pipelinesList: { setup: pipelinesListSetup }, + pipelinesCreate: { setup: pipelinesCreateSetup }, + pipelinesClone: { setup: pipelinesCloneSetup }, + pipelinesEdit: { setup: pipelinesEditSetup }, +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts new file mode 100644 index 0000000000000..d56e92a2419c4 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.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 { TestBed } from '../../../../../test_utils'; + +export const getFormActions = (testBed: TestBed) => { + const { find, form } = testBed; + + // User actions + const clickSubmitButton = () => { + find('submitButton').simulate('click'); + }; + + const clickTestPipelineButton = () => { + find('testPipelineButton').simulate('click'); + }; + + const clickShowRequestLink = () => { + find('showRequestLink').simulate('click'); + }; + + const toggleVersionSwitch = () => { + form.toggleEuiSwitch('versionToggle'); + }; + + const toggleOnFailureSwitch = () => { + form.toggleEuiSwitch('onFailureToggle'); + }; + + return { + clickSubmitButton, + clickShowRequestLink, + toggleVersionSwitch, + toggleOnFailureSwitch, + clickTestPipelineButton, + }; +}; + +export type PipelineFormTestSubjects = + | 'submitButton' + | 'pageTitle' + | 'savePipelineError' + | 'pipelineForm' + | 'versionToggle' + | 'versionField' + | 'nameField.input' + | 'descriptionField.input' + | 'processorsField' + | 'onFailureToggle' + | 'onFailureEditor' + | 'testPipelineButton' + | 'showRequestLink' + | 'requestFlyout' + | 'requestFlyout.title' + | 'testPipelineFlyout' + | 'testPipelineFlyout.title' + | 'documentationLink'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts new file mode 100644 index 0000000000000..2791ffc32c858 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesClone } from '../../../public/application/sections/pipelines_clone'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesCloneTestBed = TestBed & { + actions: ReturnType; +}; + +export const PIPELINE_TO_CLONE = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [ + { + set: { + field: 'foo', + value: 'new', + }, + }, + ], +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}create/${PIPELINE_TO_CLONE.name}`], + componentRoutePath: `${BASE_PATH}create/:name`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesClone), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.ts new file mode 100644 index 0000000000000..54a62a8357e52 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.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 { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesCreate } from '../../../public/application/sections/pipelines_create'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesCreateTestBed = TestBed & { + actions: ReturnType; +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}/create`], + componentRoutePath: `${BASE_PATH}/create`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesCreate), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts new file mode 100644 index 0000000000000..12320f034a819 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesEdit } from '../../../public/application/sections/pipelines_edit'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesEditTestBed = TestBed & { + actions: ReturnType; +}; + +export const PIPELINE_TO_EDIT = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [ + { + set: { + field: 'foo', + value: 'new', + }, + }, + ], +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}edit/${PIPELINE_TO_EDIT.name}`], + componentRoutePath: `${BASE_PATH}edit/:name`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesEdit), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts new file mode 100644 index 0000000000000..0f9745981c18b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts @@ -0,0 +1,105 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { BASE_PATH } from '../../../common/constants'; +import { + registerTestBed, + TestBed, + TestBedConfig, + findTestSubject, + nextTick, +} from '../../../../../test_utils'; +import { PipelinesList } from '../../../public/application/sections/pipelines_list'; +import { WithAppDependencies } from './setup_environment'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [BASE_PATH], + componentRoutePath: BASE_PATH, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesList), testBedConfig); + +export type PipelineListTestBed = TestBed & { + actions: ReturnType; +}; + +const createActions = (testBed: TestBed) => { + const { find } = testBed; + + /** + * User Actions + */ + const clickReloadButton = () => { + find('reloadButton').simulate('click'); + }; + + const clickPipelineAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('pipelinesTable'); + const pipelineLink = findTestSubject(rows[index].reactWrapper, 'pipelineDetailsLink'); + + await act(async () => { + const { href } = pipelineLink.props(); + router.navigateTo(href!); + await nextTick(); + component.update(); + }); + }; + + const clickActionMenu = (pipelineName: string) => { + const { component } = testBed; + + // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" + component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + }; + + const clickPipelineAction = (pipelineName: string, action: 'edit' | 'clone' | 'delete') => { + const actions = ['edit', 'clone', 'delete']; + const { component } = testBed; + + clickActionMenu(pipelineName); + + component + .find('.euiContextMenuItem') + .at(actions.indexOf(action)) + .simulate('click'); + }; + + return { + clickReloadButton, + clickPipelineAt, + clickPipelineAction, + clickActionMenu, + }; +}; + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type PipelineListTestSubjects = + | 'appTitle' + | 'documentationLink' + | 'createPipelineButton' + | 'pipelinesTable' + | 'pipelineDetails' + | 'pipelineDetails.title' + | 'deletePipelinesConfirmation' + | 'emptyList' + | 'emptyList.title' + | 'sectionLoading' + | 'pipelineLoadError' + | 'reloadButton'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx new file mode 100644 index 0000000000000..3243d665832f2 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import React from 'react'; + +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { + notificationServiceMock, + fatalErrorsServiceMock, + docLinksServiceMock, + injectedMetadataServiceMock, +} from '../../../../../../src/core/public/mocks'; + +import { usageCollectionPluginMock } from '../../../../../../src/plugins/usage_collection/public/mocks'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { HttpService } from '../../../../../../src/core/public/http'; + +import { + breadcrumbService, + documentationService, + uiMetricService, + apiService, +} from '../../../public/application/services'; + +import { init as initHttpRequests } from './http_requests'; + +const httpServiceSetupMock = new HttpService().setup({ + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + fatalErrors: fatalErrorsServiceMock.createSetupContract(), +}); + +const appServices = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications: notificationServiceMock.createSetupContract(), +}; + +export const setupEnvironment = () => { + uiMetricService.setup(usageCollectionPluginMock.createSetupContract()); + apiService.setup(httpServiceSetupMock, uiMetricService); + documentationService.setup(docLinksServiceMock.createStartContract()); + breadcrumbService.setup(() => {}); + + const { server, httpRequestsMockHelpers } = initHttpRequests(); + + return { + server, + httpRequestsMockHelpers, + }; +}; + +export const WithAppDependencies = (Comp: any) => (props: any) => ( + + + +); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx new file mode 100644 index 0000000000000..2901367892213 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import { PIPELINE_TO_CLONE, PipelinesCloneTestBed } from './helpers/pipelines_clone.helpers'; + +const { setup } = pageHelpers.pipelinesClone; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesCloneTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create pipeline'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Create pipeline docs'); + }); + + describe('form submission', () => { + it('should send the correct payload', async () => { + const { actions, waitFor } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + ...PIPELINE_TO_CLONE, + name: `${PIPELINE_TO_CLONE.name}-copy`, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx new file mode 100644 index 0000000000000..e0be8d2937729 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -0,0 +1,208 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PipelinesCreateTestBed } from './helpers/pipelines_create.helpers'; + +const { setup } = pageHelpers.pipelinesCreate; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesCreateTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create pipeline'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Create pipeline docs'); + }); + + test('should toggle the version field', async () => { + const { actions, component, exists } = testBed; + + // Version field should be hidden by default + expect(exists('versionField')).toBe(false); + + await act(async () => { + actions.toggleVersionSwitch(); + await nextTick(); + component.update(); + }); + + expect(exists('versionField')).toBe(true); + }); + + test('should toggle the on-failure processors editor', async () => { + const { actions, component, exists } = testBed; + + // On-failure editor should be hidden by default + expect(exists('onFailureEditor')).toBe(false); + + await act(async () => { + actions.toggleOnFailureSwitch(); + await nextTick(); + component.update(); + }); + + expect(exists('onFailureEditor')).toBe(true); + }); + + test('should show the request flyout', async () => { + const { actions, component, find, exists } = testBed; + + await act(async () => { + actions.clickShowRequestLink(); + await nextTick(); + component.update(); + }); + + // Verify request flyout opens + expect(exists('requestFlyout')).toBe(true); + expect(find('requestFlyout.title').text()).toBe('Request'); + }); + + describe('form validation', () => { + test('should prevent form submission if required fields are missing', async () => { + const { form, actions, component, find } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + component.update(); + }); + + expect(form.getErrorsMessages()).toEqual([ + 'Name is required.', + 'A description is required.', + ]); + expect(find('submitButton').props().disabled).toEqual(true); + + // Add required fields and verify button is enabled again + form.setInputValue('nameField.input', 'my_pipeline'); + form.setInputValue('descriptionField.input', 'pipeline description'); + + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(find('submitButton').props().disabled).toEqual(false); + }); + }); + + describe('form submission', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + + const { waitFor, form } = testBed; + + await waitFor('pipelineForm'); + + form.setInputValue('nameField.input', 'my_pipeline'); + form.setInputValue('descriptionField.input', 'pipeline description'); + }); + }); + + test('should send the correct payload', async () => { + const { actions, waitFor } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [], + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + + test('should surface API errors from the request', async () => { + const { actions, find, exists, waitFor } = testBed; + + const error = { + status: 409, + error: 'Conflict', + message: `There is already a pipeline with name 'my_pipeline'.`, + }; + + httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: error }); + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('savePipelineError'); + }); + + expect(exists('savePipelineError')).toBe(true); + expect(find('savePipelineError').text()).toContain(error.message); + }); + }); + + describe('test pipeline', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + + const { waitFor } = testBed; + + await waitFor('pipelineForm'); + }); + }); + + test('should open the test pipeline flyout', async () => { + const { actions, exists, find, waitFor } = testBed; + + await act(async () => { + actions.clickTestPipelineButton(); + await waitFor('testPipelineFlyout'); + }); + + // Verify test pipeline flyout opens + expect(exists('testPipelineFlyout')).toBe(true); + expect(find('testPipelineFlyout.title').text()).toBe('Test pipeline'); + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx new file mode 100644 index 0000000000000..477eec83f876d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import { PIPELINE_TO_EDIT, PipelinesEditTestBed } from './helpers/pipelines_edit.helpers'; + +const { setup } = pageHelpers.pipelinesEdit; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesEditTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual(`Edit pipeline '${PIPELINE_TO_EDIT.name}'`); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Edit pipeline docs'); + }); + + it('should disable the name field', () => { + const { find } = testBed; + + const nameInput = find('nameField.input'); + expect(nameInput.props().disabled).toEqual(true); + }); + + describe('form submission', () => { + it('should send the correct payload with changed values', async () => { + const UPDATED_DESCRIPTION = 'updated pipeline description'; + const { actions, form, waitFor } = testBed; + + // Make change to description field + form.setInputValue('descriptionField.input', UPDATED_DESCRIPTION); + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const { name, ...pipelineDefinition } = PIPELINE_TO_EDIT; + + const expected = { + ...pipelineDefinition, + description: UPDATED_DESCRIPTION, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts new file mode 100644 index 0000000000000..3e0b78d4f2e9d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { API_BASE_PATH } from '../../common/constants'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PipelineListTestBed } from './helpers/pipelines_list.helpers'; + +const { setup } = pageHelpers.pipelinesList; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +describe('', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: PipelineListTestBed; + + afterAll(() => { + server.restore(); + }); + + describe('With pipelines', () => { + const pipeline1 = { + name: 'test_pipeline1', + description: 'test_pipeline1 description', + processors: [], + }; + + const pipeline2 = { + name: 'test_pipeline2', + description: 'test_pipeline2 description', + processors: [], + }; + + const pipelines = [pipeline1, pipeline2]; + + httpRequestsMockHelpers.setLoadPipelinesResponse(pipelines); + + beforeEach(async () => { + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('pipelinesTable'); + }); + }); + + test('should render the list view', async () => { + const { exists, find, table } = testBed; + + // Verify app title + expect(exists('appTitle')).toBe(true); + expect(find('appTitle').text()).toEqual('Ingest Node Pipelines'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Ingest Node Pipelines docs'); + + // Verify create button exists + expect(exists('createPipelineButton')).toBe(true); + + // Verify table content + const { tableCellsValues } = table.getMetaData('pipelinesTable'); + tableCellsValues.forEach((row, i) => { + const pipeline = pipelines[i]; + + expect(row).toEqual(['', pipeline.name, '']); + }); + }); + + test('should reload the pipeline data', async () => { + const { component, actions } = testBed; + const totalRequests = server.requests.length; + + await act(async () => { + actions.clickReloadButton(); + await nextTick(100); + component.update(); + }); + + expect(server.requests.length).toBe(totalRequests + 1); + expect(server.requests[server.requests.length - 1].url).toBe(API_BASE_PATH); + }); + + test('should show the details of a pipeline', async () => { + const { find, exists, actions } = testBed; + + await actions.clickPipelineAt(0); + + expect(exists('pipelinesTable')).toBe(true); + expect(exists('pipelineDetails')).toBe(true); + expect(find('pipelineDetails.title').text()).toBe(pipeline1.name); + }); + + test('should delete a pipeline', async () => { + const { actions, component } = testBed; + const { name: pipelineName } = pipeline1; + + httpRequestsMockHelpers.setDeletePipelineResponse({ + itemsDeleted: [pipelineName], + errors: [], + }); + + actions.clickPipelineAction(pipelineName, 'delete'); + + // We need to read the document "body" as the modal is added there and not inside + // the component DOM tree. + const modal = document.body.querySelector('[data-test-subj="deletePipelinesConfirmation"]'); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + expect(modal).not.toBe(null); + expect(modal!.textContent).toContain('Delete pipeline'); + + await act(async () => { + confirmButton!.click(); + await nextTick(); + component.update(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(latestRequest.method).toBe('DELETE'); + expect(latestRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); + expect(latestRequest.status).toEqual(200); + }); + }); + + describe('No pipelines', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelinesResponse([]); + + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('emptyList'); + }); + }); + + test('should display an empty prompt', async () => { + const { exists, find } = testBed; + + expect(exists('sectionLoading')).toBe(false); + expect(exists('emptyList')).toBe(true); + expect(find('emptyList.title').text()).toEqual('Start by creating a pipeline'); + }); + }); + + describe('Error handling', () => { + beforeEach(async () => { + const error = { + status: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadPipelinesResponse(undefined, { body: error }); + + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('pipelineLoadError'); + }); + }); + + test('should render an error message if error fetching pipelines', async () => { + const { exists, find } = testBed; + + expect(exists('pipelineLoadError')).toBe(true); + expect(find('pipelineLoadError').text()).toContain('Unable to load pipelines.'); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index 9082196a48b39..55523bfa7d116 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -126,6 +126,7 @@ export const PipelineForm: React.FunctionComponent = ({ setIsRequestVisible(prevIsRequestVisible => !prevIsRequestVisible)} > {isRequestVisible ? ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index b90683426887f..8144228b1e9d5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -140,7 +140,12 @@ export const PipelineFormFields: React.FunctionComponent = ({ - + = ({ path="processors" component={JsonEditorField} componentProps={{ - ['data-test-subj']: 'processorsField', euiCodeEditorProps: { + ['data-test-subj']: 'processorsField', height: '300px', 'aria-label': i18n.translate('xpack.ingestPipelines.form.processorsFieldAriaLabel', { defaultMessage: 'Processors JSON editor', @@ -211,8 +216,8 @@ export const PipelineFormFields: React.FunctionComponent = ({ path="on_failure" component={JsonEditorField} componentProps={{ - ['data-test-subj']: 'onFailureEditor', euiCodeEditorProps: { + ['data-test-subj']: 'onFailureEditor', height: '300px', 'aria-label': i18n.translate('xpack.ingestPipelines.form.onFailureFieldAriaLabel', { defaultMessage: 'Failure processors JSON editor', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx index 7cfe887d68d52..2ab7e84b3bb2b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx @@ -40,10 +40,10 @@ export const PipelineRequestFlyout: React.FunctionComponent = ({ uuid.current++; return ( - + -

+

{name ? ( + -

+

{pipeline.name ? ( - -

+ +

- -

+ +

= ({ - +

{pipeline.name}

diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx index 318a9219b2010..f6fe2f0cf65fa 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx @@ -18,8 +18,9 @@ export const EmptyList: FunctionComponent = () => { +

{i18n.translate('xpack.ingestPipelines.list.table.emptyPromptTitle', { defaultMessage: 'Start by creating a pipeline', })} diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 23d105c807c8b..948290b169134 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -80,11 +80,15 @@ export const PipelinesList: React.FunctionComponent = ({ history.push(BASE_PATH); }; + if (data && data.length === 0) { + return ; + } + let content: React.ReactNode; if (isLoading) { content = ( - + = ({ pipelines={data} /> ); - } else { - return ; } const renderFlyout = (): React.ReactNode => { @@ -148,6 +150,7 @@ export const PipelinesList: React.FunctionComponent = ({ href={services.documentation.getIngestNodeUrl()} target="_blank" iconType="help" + data-test-subj="documentationLink" > = ({ = ({ const tableProps: EuiInMemoryTableProps = { itemId: 'name', isSelectable: true, + 'data-test-subj': 'pipelinesTable', sorting: { sort: { field: 'name', direction: 'asc' } }, selection: { onSelectionChange: setSelection, @@ -91,7 +92,11 @@ export const PipelineTable: FunctionComponent = ({ defaultMessage: 'Name', }), sortable: true, - render: (name: string) => {name}, + render: (name: string) => ( + + {name} + + ), }, { name: ( diff --git a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx index be6830c115836..08f55850b119e 100644 --- a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx +++ b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useMemo, memo, FunctionComponent } from 'react'; +import React, { useState, useMemo, useEffect, memo, FunctionComponent } from 'react'; import { debounce } from 'lodash'; /** @@ -17,7 +17,11 @@ export function debouncedComponent(component: FunctionComponent, return (props: TProps) => { const [cachedProps, setCachedProps] = useState(props); - const delayRender = useMemo(() => debounce(setCachedProps, delay), []); + const debouncePropsChange = debounce(setCachedProps, delay); + const delayRender = useMemo(() => debouncePropsChange, []); + + // cancel debounced prop change if component has been unmounted in the meantime + useEffect(() => () => debouncePropsChange.cancel(), []); delayRender(props); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index f7be82dd34ba3..81476e8fa3708 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -43,6 +43,12 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); + const [popoverState, setPopoverState] = useState({ + isOpen: false, + openId: null, + addingToGroupId: null, + }); + const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; if (!datasourcePublicAPI) { @@ -74,12 +80,6 @@ export function LayerPanel( dateRange: props.framePublicAPI.dateRange, }; - const [popoverState, setPopoverState] = useState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some(d => d.accessors.length > 0); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 5cd803e7cebbc..6da9a94711081 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -61,6 +61,8 @@ export function EditorFrame(props: EditorFrameProps) { // Initialize current datasource and all active datasources useEffect(() => { + // prevents executing dispatch on unmounted component + let isUnmounted = false; if (!allLoaded) { Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { if ( @@ -70,16 +72,21 @@ export function EditorFrame(props: EditorFrameProps) { datasource .initialize(state.datasourceStates[datasourceId].state || undefined) .then(datasourceState => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceState, - datasourceId, - }); + if (!isUnmounted) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceState, + datasourceId, + }); + } }) .catch(onError); } }); } + return () => { + isUnmounted = true; + }; }, [allLoaded]); const datasourceLayers: Record = {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx index 1f741ca37934f..e246d8e27a708 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx @@ -122,6 +122,16 @@ export function InnerWorkspacePanel({ framePublicAPI.filters, ]); + useEffect(() => { + // reset expression error if component attempts to run it again + if (expression && localState.expressionBuildError) { + setLocalState(s => ({ + ...s, + expressionBuildError: undefined, + })); + } + }, [expression]); + function onDrop() { if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); @@ -174,16 +184,6 @@ export function InnerWorkspacePanel({ } function renderVisualization() { - useEffect(() => { - // reset expression error if component attempts to run it again - if (expression && localState.expressionBuildError) { - setLocalState(s => ({ - ...s, - expressionBuildError: undefined, - })); - } - }, [expression]); - if (expression === null) { return renderEmptyWorkspace(); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index c396f0efee42e..5e3b32f6961e6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -258,7 +258,17 @@ describe('IndexPattern Data Panel', () => { it('should render a warning if there are no index patterns', () => { const wrapper = shallowWithIntl( - + {} }} + changeIndexPattern={jest.fn()} + /> ); expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 79dcdafd916b4..b013f2b9d22a6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -144,21 +144,49 @@ export function IndexPatternDataPanel({ indexPatternList.map(x => `${x.title}:${x.timeFieldName}`).join(','), ]} /> - + + {Object.keys(indexPatterns).length === 0 ? ( + + + +

+ +

+
+
+
+ ) : ( + + )} ); } @@ -194,35 +222,6 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ onChangeIndexPattern: (newId: string) => void; existingFields: IndexPatternPrivateState['existingFields']; }) { - if (Object.keys(indexPatterns).length === 0) { - return ( - - - -

- -

-
-
-
- ); - } - const [localState, setLocalState] = useState({ nameFilter: '', typeFilter: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx index 04e13fead6fca..7e2af6a19b041 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -127,7 +127,7 @@ export function BucketNestingEditor({ defaultMessage: 'Entire data set', }), }, - ...aggColumns, + ...aggColumns.map(({ value, text }) => ({ value, text })), ]} value={prevColumn} onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index c4d2a6f8780c6..5f0fa95ad0022 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -251,22 +251,6 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; - - if (props.isLoading) { - return ; - } else if ( - (!props.histogram || props.histogram.buckets.length === 0) && - (!props.topValues || props.topValues.buckets.length === 0) - ) { - return ( - - {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { - defaultMessage: 'No data to display.', - })} - - ); - } - let histogramDefault = !!props.histogram; const totalValuesCount = @@ -309,6 +293,21 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { let title = <>; + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { title = ( { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { if (method === 'ml.privilegeCheck') { resolve({ @@ -28,34 +31,19 @@ const callWithRequest = method => { return; } resolve({}); - }); + }) as Promise; }; -const callWithInternalUser = () => { +const callWithInternalUser: APICaller = () => { return new Promise(resolve => { resolve({}); - }); + }) as Promise; }; -// mock xpack_main plugin -function mockXpackMainPluginFactory(isEnabled = false, licenseType = 'platinum') { - return { - info: { - isAvailable: () => true, - feature: () => ({ - isEnabled: () => isEnabled, - }), - license: { - getType: () => licenseType, - }, - }, - }; -} - // mock configuration to be passed to the estimator -const formConfig = { - aggTypes: ['count'], - duration: {}, +const formConfig: BucketSpanEstimatorData = { + aggTypes: [ES_AGGREGATION.COUNT], + duration: { start: 0, end: 1 }, fields: [null], index: '', query: { @@ -64,13 +52,15 @@ const formConfig = { must_not: [], }, }, + splitField: undefined, + timeField: undefined, }; describe('ML - BucketSpanEstimator', () => { it('call factory', () => { expect(function() { - estimateBucketSpanFactory(callWithRequest, callWithInternalUser); - }).to.not.throwError('Not initialized.'); + estimateBucketSpanFactory(callWithRequest, callWithInternalUser, false); + }).not.toThrow('Not initialized.'); }); it('call factory and estimator with security disabled', done => { @@ -78,44 +68,29 @@ describe('ML - BucketSpanEstimator', () => { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory() + true ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); - it('call factory and estimator with security enabled and sufficient permissions.', done => { + it('call factory and estimator with security enabled.', done => { expect(function() { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory(true) + false ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); - }); - - it('call factory and estimator with security enabled and insufficient permissions.', done => { - expect(function() { - const estimateBucketSpan = estimateBucketSpanFactory( - callWithRequest, - callWithInternalUser, - mockXpackMainPluginFactory(true) - ); - - estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Insufficient permissions to call bucket span estimation.'); - done(); - }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts index 33f5d5ec95fad..6a9a7a0c13395 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts @@ -6,14 +6,19 @@ import { APICaller } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; + +import { DeepPartial } from '../../../common/types/common'; + import { validateJobSchema } from '../../routes/schemas/job_validation_schema'; -type ValidateJobPayload = TypeOf; +import { ValidationMessage } from './messages'; + +export type ValidateJobPayload = TypeOf; export function validateJob( callAsCurrentUser: APICaller, - payload: ValidateJobPayload, - kbnVersion: string, - callAsInternalUser: APICaller, - isSecurityDisabled: boolean -): string[]; + payload?: DeepPartial, + kbnVersion?: string, + callAsInternalUser?: APICaller, + isSecurityDisabled?: boolean +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts similarity index 80% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js rename to x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index 726a8e8d8db85..9851f80a42d5b 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -4,16 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateJob } from '../job_validation'; +import { APICaller } from 'kibana/server'; + +import { validateJob } from './job_validation'; // mock callWithRequest -const callWithRequest = () => { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { + if (method === 'fieldCaps') { + resolve({ fields: [] }); + return; + } resolve({}); - }); + }) as Promise; }; +// Note: The tests cast `payload` as any +// so we can simulate possible runtime payloads +// that don't satisfy the TypeScript specs. describe('ML - validateJob', () => { it('calling factory without payload throws an error', done => { validateJob(callWithRequest).then( @@ -61,7 +69,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_empty', 'detectors_empty', 'bucket_span_empty', @@ -70,10 +78,14 @@ describe('ML - validateJob', () => { }); }); - const jobIdTests = (testIds, messageId) => { + const jobIdTests = (testIds: string[], messageId: string) => { const promises = testIds.map(id => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.job_id = id; + const payload = { + job: { + analysis_config: { detectors: [] }, + job_id: id, + }, + }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for jobIdTests.'); }); @@ -81,19 +93,21 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; - const jobGroupIdTest = (testIds, messageId) => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.groups = testIds; + const jobGroupIdTest = (testIds: string[], messageId: string) => { + const payload = { job: { analysis_config: { detectors: [] }, groups: testIds } }; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(ids.includes(messageId)).toBe(true); }); }; @@ -126,10 +140,9 @@ describe('ML - validateJob', () => { return jobGroupIdTest(validTestIds, 'job_group_id_valid'); }); - const bucketSpanFormatTests = (testFormats, messageId) => { + const bucketSpanFormatTests = (testFormats: string[], messageId: string) => { const promises = testFormats.map(format => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.analysis_config.bucket_span = format; + const payload = { job: { analysis_config: { bucket_span: format, detectors: [] } } }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for bucketSpanFormatTests.'); }); @@ -137,8 +150,11 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; @@ -152,7 +168,7 @@ describe('ML - validateJob', () => { }); it('at least one detector function is empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); @@ -165,19 +181,19 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_empty')).to.equal(true); + expect(ids.includes('detectors_function_empty')).toBe(true); }); }); it('detector function is not empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_not_empty')).to.equal(true); + expect(ids.includes('detectors_function_not_empty')).toBe(true); }); }); @@ -189,7 +205,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_invalid')).to.equal(true); + expect(ids.includes('index_fields_invalid')).toBe(true); }); }); @@ -201,11 +217,11 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_valid')).to.equal(true); + expect(ids.includes('index_fields_valid')).toBe(true); }); }); - const getBasicPayload = () => ({ + const getBasicPayload = (): any => ({ job: { job_id: 'test', analysis_config: { @@ -214,7 +230,7 @@ describe('ML - validateJob', () => { { function: 'count', }, - ], + ] as Array<{ function: string; by_field_name?: string; partition_field_name?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -224,7 +240,7 @@ describe('ML - validateJob', () => { }); it('throws an error because job.analysis_config.influencers is not an Array', done => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; delete payload.job.analysis_config.influencers; validateJob(callWithRequest, payload).then( @@ -237,11 +253,11 @@ describe('ML - validateJob', () => { }); it('detect duplicate detectors', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; payload.job.analysis_config.detectors.push({ function: 'count' }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'detectors_duplicates', @@ -253,7 +269,7 @@ describe('ML - validateJob', () => { }); it('dedupe duplicate messages', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; // in this test setup, the following configuration passes // the duplicate detectors check, but would return the same // 'field_not_aggregatable' message for both detectors. @@ -264,7 +280,7 @@ describe('ML - validateJob', () => { ]; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -278,7 +294,7 @@ describe('ML - validateJob', () => { const payload = getBasicPayload(); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -288,7 +304,7 @@ describe('ML - validateJob', () => { }); it('categorization job using mlcategory passes aggregatable field check', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -310,7 +326,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -322,7 +338,7 @@ describe('ML - validateJob', () => { }); it('non-existent field reported as non aggregatable', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -343,7 +359,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -354,7 +370,7 @@ describe('ML - validateJob', () => { }); it('script field not reported as non aggregatable', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -385,7 +401,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -399,19 +415,19 @@ describe('ML - validateJob', () => { // the following two tests validate the correct template rendering of // urls in messages with {{version}} in them to be replaced with the // specified version. (defaulting to 'current') - const docsTestPayload = getBasicPayload(); + const docsTestPayload = getBasicPayload() as any; docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }]; it('creates a docs url pointing to the current docs version', () => { return validateJob(callWithRequest, docsTestPayload).then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/current/')).not.to.be(-1); + expect(message.url.search('/current/')).not.toBe(-1); }); }); it('creates a docs url pointing to the master docs version', () => { return validateJob(callWithRequest, docsTestPayload, 'master').then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/master/')).not.to.be(-1); + expect(message.url.search('/master/')).not.toBe(-1); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.d.ts b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts new file mode 100644 index 0000000000000..772d78b4187dd --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ValidationMessage { + id: string; + url: string; +} diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts similarity index 81% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js rename to x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts index 3dc2bee1e8705..4001697d74320 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateBucketSpan } from '../validate_bucket_span'; -import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../../common/constants/validation'; +import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation'; + +import { ValidationMessage } from './messages'; +// @ts-ignore +import { validateBucketSpan } from './validate_bucket_span'; // farequote2017 snapshot snapshot mock search response // it returns a mock for the response of PolledDataChecker's search request // to get an aggregation of non_empty_buckets with an interval of 1m. // this allows us to test bucket span estimation. -import mockFareQuoteSearchResponse from './mock_farequote_search_response'; +import mockFareQuoteSearchResponse from './__mocks__/mock_farequote_search_response.json'; // it_ops_app_logs 2017 snapshot mock search response // sparse data with a low number of buckets -import mockItSearchResponse from './mock_it_search_response'; +import mockItSearchResponse from './__mocks__/mock_it_search_response.json'; // mock callWithRequestFactory -const callWithRequestFactory = mockSearchResponse => { +const callWithRequestFactory = (mockSearchResponse: any) => { return () => { return new Promise(resolve => { resolve(mockSearchResponse); @@ -86,17 +88,17 @@ describe('ML - validateBucketSpan', () => { }; return validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); } ); }); - const getJobConfig = bucketSpan => ({ + const getJobConfig = (bucketSpan: string) => ({ analysis_config: { bucket_span: bucketSpan, - detectors: [], + detectors: [] as Array<{ function?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -111,9 +113,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); @@ -125,9 +127,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['bucket_span_high']); + expect(ids).toStrictEqual(['bucket_span_high']); }); }); @@ -135,14 +137,18 @@ describe('ML - validateBucketSpan', () => { return; } - const testBucketSpan = (bucketSpan, mockSearchResponse, test) => { + const testBucketSpan = ( + bucketSpan: string, + mockSearchResponse: any, + test: (ids: string[]) => void + ) => { const job = getJobConfig(bucketSpan); job.analysis_config.detectors.push({ function: 'count', }); return validateBucketSpan(callWithRequestFactory(mockSearchResponse), job, {}).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); test(ids); } @@ -151,13 +157,13 @@ describe('ML - validateBucketSpan', () => { it('farequote count detector, bucket span estimation matches 15m', () => { return testBucketSpan('15m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); it('farequote count detector, bucket span estimation does not match 1m', () => { return testBucketSpan('1m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['bucket_span_estimation_mismatch']); + expect(ids).toStrictEqual(['bucket_span_estimation_mismatch']); }); }); @@ -167,7 +173,7 @@ describe('ML - validateBucketSpan', () => { // should result in a lower bucket span estimation. it('it_ops_app_logs count detector, bucket span estimation matches 6h', () => { return testBucketSpan('6h', mockItSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts index 22d2fec0beddc..2fad1252e6446 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts @@ -7,4 +7,7 @@ import { APICaller } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateCardinality(callAsCurrentUser: APICaller, job: CombinedJob): any[]; +export function validateCardinality( + callAsCurrentUser: APICaller, + job?: CombinedJob +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts similarity index 69% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js rename to x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts index 9617982a66b0e..e5111629f1182 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts @@ -5,11 +5,15 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { validateCardinality } from '../validate_cardinality'; -import mockFareQuoteCardinality from './mock_farequote_cardinality'; -import mockFieldCaps from './mock_field_caps'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import mockFareQuoteCardinality from './__mocks__/mock_farequote_cardinality.json'; +import mockFieldCaps from './__mocks__/mock_field_caps.json'; + +import { validateCardinality } from './validate_cardinality'; const mockResponses = { search: mockFareQuoteCardinality, @@ -17,8 +21,8 @@ const mockResponses = { }; // mock callWithRequestFactory -const callWithRequestFactory = (responses, fail = false) => { - return requestName => { +const callWithRequestFactory = (responses: Record, fail = false): APICaller => { + return (requestName: string) => { return new Promise((resolve, reject) => { const response = responses[requestName]; if (fail) { @@ -26,7 +30,7 @@ const callWithRequestFactory = (responses, fail = false) => { } else { resolve(response); } - }); + }) as Promise; }; }; @@ -39,21 +43,23 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), {}).then( + validateCardinality(callWithRequestFactory(mockResponses), {} as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), { analysis_config: {} }).then( + validateCardinality(callWithRequestFactory(mockResponses), { + analysis_config: {}, + } as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #3, missing datafeed_config.indices', done => { - const job = { analysis_config: {}, datafeed_config: {} }; + const job = { analysis_config: {}, datafeed_config: {} } as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -61,7 +67,10 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #4, missing data_description', done => { - const job = { analysis_config: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -69,7 +78,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #5, missing data_description.time_field', done => { - const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + data_description: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -77,11 +90,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #6, missing analysis_config.influencers', done => { - const job = { + const job = ({ analysis_config: {}, datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, - }; + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -89,21 +102,21 @@ describe('ML - validateCardinality', () => { }); it('minimum job configuration to pass cardinality check code', () => { - const job = { + const job = ({ analysis_config: { detectors: [], influencers: [] }, data_description: { time_field: '@timestamp' }, datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); - const getJobConfig = fieldName => ({ + const getJobConfig = (fieldName: string) => ({ analysis_config: { detectors: [ { @@ -119,11 +132,18 @@ describe('ML - validateCardinality', () => { }, }); - const testCardinality = (fieldName, cardinality, test) => { + const testCardinality = ( + fieldName: string, + cardinality: number, + test: (ids: string[]) => void + ) => { const job = getJobConfig(fieldName); const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job, {}).then(messages => { + return validateCardinality( + callWithRequestFactory(mockCardinality), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); test(ids); }); @@ -132,26 +152,34 @@ describe('ML - validateCardinality', () => { it(`field '_source' not aggregatable`, () => { const job = getJobConfig('partition_field_name'); job.analysis_config.detectors[0].partition_field_name = '_source'; - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); + expect(ids).toStrictEqual(['field_not_aggregatable']); }); }); it(`field 'airline' aggregatable`, () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('field not aggregatable', () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory({}), job).then(messages => { - const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); - }); + return validateCardinality(callWithRequestFactory({}), (job as unknown) as CombinedJob).then( + messages => { + const ids = messages.map(m => m.id); + expect(ids).toStrictEqual(['field_not_aggregatable']); + } + ); }); it('fields not aggregatable', () => { @@ -160,107 +188,110 @@ describe('ML - validateCardinality', () => { function: 'count', partition_field_name: 'airline', }); - return validateCardinality(callWithRequestFactory({}, true), job).then(messages => { + return validateCardinality( + callWithRequestFactory({}, true), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['fields_not_aggregatable']); + expect(ids).toStrictEqual(['fields_not_aggregatable']); }); }); it('valid partition field cardinality', () => { return testCardinality('partition_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high partition field cardinality', () => { return testCardinality('partition_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_partition_field']); + expect(ids).toStrictEqual(['cardinality_partition_field']); }); }); it('valid by field cardinality', () => { return testCardinality('by_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high by field cardinality', () => { return testCardinality('by_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it('valid over field cardinality', () => { return testCardinality('over_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too low over field cardinality', () => { return testCardinality('over_field_name', 9, ids => { - expect(ids).to.eql(['cardinality_over_field_low']); + expect(ids).toStrictEqual(['cardinality_over_field_low']); }); }); it('too high over field cardinality', () => { return testCardinality('over_field_name', 1000001, ids => { - expect(ids).to.eql(['cardinality_over_field_high']); + expect(ids).toStrictEqual(['cardinality_over_field_high']); }); }); const cardinality = 10000; it(`disabled model_plot, over field cardinality of ${cardinality} doesn't trigger a warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it(`enabled model_plot, over field cardinality of ${cardinality} triggers a model plot warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high']); + expect(ids).toStrictEqual(['cardinality_model_plot_high']); }); }); it(`disabled model_plot, by field cardinality of ${cardinality} triggers a field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it(`enabled model_plot, by field cardinality of ${cardinality} triggers a model plot warning and field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high', 'cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_model_plot_high', 'cardinality_by_field']); }); }); it(`enabled model_plot with terms, by field cardinality of ${cardinality} triggers just field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true, terms: 'AAL,AAB' }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts similarity index 63% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts index 06b2e5205fdbd..df3310ad9f5e8 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateInfluencers } from '../validate_influencers'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { validateInfluencers } from './validate_influencers'; describe('ML - validateInfluencers', () => { it('called without arguments throws an error', done => { - validateInfluencers().then( + validateInfluencers( + (undefined as unknown) as APICaller, + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateInfluencers(undefined, {}).then( + validateInfluencers((undefined as unknown) as APICaller, ({} as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -28,7 +34,7 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -40,25 +46,29 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); - const getJobConfig = (influencers = [], detectors = []) => ({ - analysis_config: { detectors, influencers }, - data_description: { time_field: '@timestamp' }, - datafeed_config: { - indices: [], - }, - }); + const getJobConfig: ( + influencers?: string[], + detectors?: CombinedJob['analysis_config']['detectors'] + ) => CombinedJob = (influencers = [], detectors = []) => + (({ + analysis_config: { detectors, influencers }, + data_description: { time_field: '@timestamp' }, + datafeed_config: { + indices: [], + }, + } as unknown) as CombinedJob); it('success_influencer', () => { const job = getJobConfig(['airline']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_influencers']); + expect(ids).toStrictEqual(['success_influencers']); }); }); @@ -69,31 +79,30 @@ describe('ML - validateInfluencers', () => { { detector_description: 'count', function: 'count', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); it('influencer_low', () => { const job = getJobConfig(); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low']); + expect(ids).toStrictEqual(['influencer_low']); }); }); it('influencer_high', () => { const job = getJobConfig(['i1', 'i2', 'i3', 'i4']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_high']); + expect(ids).toStrictEqual(['influencer_high']); }); }); @@ -105,14 +114,13 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'airline', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low_suggestion']); + expect(ids).toStrictEqual(['influencer_low_suggestion']); }); }); @@ -124,27 +132,24 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'partition_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', by_field_name: 'by_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', over_field_name: 'over_field', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { - expect(messages).to.eql([ + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { + expect(messages).toStrictEqual([ { id: 'influencer_low_suggestions', influencerSuggestion: '["partition_field","by_field","over_field"]', diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts similarity index 89% rename from x-pack/plugins/ml/server/models/job_validation/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts index 60fd5c37b9958..e54ffc4586a8e 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts @@ -4,19 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + import { validateJobObject } from './validate_job_object'; const INFLUENCER_LOW_THRESHOLD = 0; const INFLUENCER_HIGH_THRESHOLD = 4; const DETECTOR_FIELD_NAMES_THRESHOLD = 1; -export async function validateInfluencers(callWithRequest, job) { +export async function validateInfluencers(callWithRequest: APICaller, job: CombinedJob) { validateJobObject(job); const messages = []; const influencers = job.analysis_config.influencers; - const detectorFieldNames = []; + const detectorFieldNames: string[] = []; job.analysis_config.detectors.forEach(d => { if (d.by_field_name) { detectorFieldNames.push(d.by_field_name); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts similarity index 76% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js rename to x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts index e3ef62e507485..2c3b2dd4dc6ae 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts @@ -5,28 +5,32 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { isValidTimeField, validateTimeRange } from '../validate_time_range'; -import mockTimeField from './mock_time_field'; -import mockTimeFieldNested from './mock_time_field_nested'; -import mockTimeRange from './mock_time_range'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { isValidTimeField, validateTimeRange } from './validate_time_range'; + +import mockTimeField from './__mocks__/mock_time_field.json'; +import mockTimeFieldNested from './__mocks__/mock_time_field_nested.json'; +import mockTimeRange from './__mocks__/mock_time_range.json'; const mockSearchResponse = { fieldCaps: mockTimeField, search: mockTimeRange, }; -const callWithRequestFactory = resp => { - return path => { +const callWithRequestFactory = (resp: any): APICaller => { + return (path: string) => { return new Promise(resolve => { resolve(resp[path]); - }); + }) as Promise; }; }; function getMinimalValidJob() { - return { + return ({ analysis_config: { bucket_span: '15m', detectors: [], @@ -36,12 +40,15 @@ function getMinimalValidJob() { datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; } describe('ML - isValidTimeField', () => { it('called without job config argument triggers Promise rejection', done => { - isValidTimeField(callWithRequestFactory(mockSearchResponse)).then( + isValidTimeField( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); @@ -50,7 +57,7 @@ describe('ML - isValidTimeField', () => { it('time_field `@timestamp`', done => { isValidTimeField(callWithRequestFactory(mockSearchResponse), getMinimalValidJob()).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `@timestamp`.')) @@ -71,7 +78,7 @@ describe('ML - isValidTimeField', () => { mockJobConfigNestedDate ).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `metadata.timestamp`.')) @@ -81,14 +88,19 @@ describe('ML - isValidTimeField', () => { describe('ML - validateTimeRange', () => { it('called without arguments', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse)).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse), { analysis_config: {} }).then( + validateTimeRange(callWithRequestFactory(mockSearchResponse), ({ + analysis_config: {}, + } as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -96,7 +108,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #3, missing datafeed_config.indices', done => { const job = { analysis_config: {}, datafeed_config: {} }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -104,7 +119,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #4, missing data_description', done => { const job = { analysis_config: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -112,7 +130,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #5, missing data_description.time_field', done => { const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -128,7 +149,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_field_invalid']); + expect(ids).toStrictEqual(['time_field_invalid']); }); }); @@ -142,7 +163,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -154,7 +175,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -166,7 +187,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -178,7 +199,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_time_range']); + expect(ids).toStrictEqual(['success_time_range']); }); }); @@ -190,7 +211,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_before_epoch']); + expect(ids).toStrictEqual(['time_range_before_epoch']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index 5f73438769851..4fb09af94dcc6 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -37,9 +37,9 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin fields: [timeField], }); - let fieldType = fieldCaps.fields[timeField]?.date?.type; + let fieldType = fieldCaps?.fields[timeField]?.date?.type; if (fieldType === undefined) { - fieldType = fieldCaps.fields[timeField]?.date_nanos?.type; + fieldType = fieldCaps?.fields[timeField]?.date_nanos?.type; } return fieldType === ES_FIELD_TYPES.DATE || fieldType === ES_FIELD_TYPES.DATE_NANOS; } @@ -47,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin export async function validateTimeRange( callAsCurrentUser: APICaller, job: CombinedJob, - timeRange: TimeRange | undefined + timeRange?: TimeRange ) { const messages: ValidateTimeRangeMessage[] = []; diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index b90a9aa7d139a..0722a80dc2c11 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -140,7 +140,7 @@ export class BulkUploader { async _fetchAndUpload(usageCollection) { const collectorsReady = await usageCollection.areAllCollectorsReady(); const hasUsageCollectors = usageCollection.some(usageCollection.isUsageCollector); - if (!collectorsReady) { + if (!collectorsReady || typeof this.kibanaStatusGetter !== 'function') { this._log.debug('Skipping bulk uploading because not all collectors are ready'); if (hasUsageCollectors) { this._lastFetchUsageTime = null; @@ -151,7 +151,7 @@ export class BulkUploader { const data = await usageCollection.bulkFetch(this._cluster.callAsInternalUser); const payload = this.toBulkUploadFormat(compact(data), usageCollection); - if (payload) { + if (payload && payload.length > 0) { try { this._log.debug(`Uploading bulk stats payload to the local cluster`); const result = await this._onPayload(payload); @@ -244,7 +244,7 @@ export class BulkUploader { */ toBulkUploadFormat(rawData, usageCollection) { if (rawData.length === 0) { - return; + return []; } // convert the raw data to a nested object by taking each payload through diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx index 9c3d1c90e67d7..337ca2e3c918e 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx @@ -109,3 +109,6 @@ export const JiraConnectorFlyout = withConnectorFlyout({ configKeys: ['projectKey'], connectorActionTypeId: '.jira', }); + +// eslint-disable-next-line import/no-default-export +export { JiraConnectorFlyout as default }; diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx b/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx index ada9608e37c98..049ccb7cf17b7 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { lazy } from 'react'; import { ValidationResult, // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -13,7 +14,6 @@ import { connector } from './config'; import { createActionType } from '../utils'; import logo from './logo.svg'; import { JiraActionConnector } from './types'; -import { JiraConnectorFlyout } from './flyout'; import * as i18n from './translations'; interface Errors { @@ -50,5 +50,5 @@ export const getActionType = createActionType({ selectMessage: i18n.JIRA_DESC, actionTypeTitle: connector.name, validateConnector, - actionConnectorFields: JiraConnectorFlyout, + actionConnectorFields: lazy(() => import('./flyout')), }); diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx index 5d5d08dacf90c..2783e988a6405 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx @@ -82,3 +82,6 @@ export const ServiceNowConnectorFlyout = withConnectorFlyout import('./flyout')), }); diff --git a/x-pack/plugins/siem/public/lib/connectors/types.ts b/x-pack/plugins/siem/public/lib/connectors/types.ts index ffb013c347e59..3d3692c9806e4 100644 --- a/x-pack/plugins/siem/public/lib/connectors/types.ts +++ b/x-pack/plugins/siem/public/lib/connectors/types.ts @@ -8,6 +8,7 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import { ActionType } from '../../../../triggers_actions_ui/public'; +import { IErrorObject } from '../../../../triggers_actions_ui/public/types'; import { ExternalIncidentServiceConfiguration } from '../../../../actions/server/builtin_action_types/case/types'; import { ActionType as ThirdPartySupportedActions, CaseField } from '../../../../case/common/api'; @@ -42,7 +43,7 @@ export interface ActionConnectorValidationErrors { export type Optional = Omit & Partial; export interface ConnectorFlyoutFormProps { - errors: { [key: string]: string[] }; + errors: IErrorObject; action: T; onChangeSecret: (key: string, value: string) => void; onBlurSecret: (key: string) => void; diff --git a/x-pack/plugins/siem/public/lib/connectors/utils.ts b/x-pack/plugins/siem/public/lib/connectors/utils.ts index 169b4758876e8..cc1608a05e2ce 100644 --- a/x-pack/plugins/siem/public/lib/connectors/utils.ts +++ b/x-pack/plugins/siem/public/lib/connectors/utils.ts @@ -7,7 +7,6 @@ import { ActionTypeModel, ValidationResult, - ActionParamsProps, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../triggers_actions_ui/public/types'; @@ -31,7 +30,7 @@ export const createActionType = ({ validateConnector, validateParams = connectorParamsValidator, actionConnectorFields, - actionParamsFields = ConnectorParamsFields, + actionParamsFields = null, }: Optional) => (): ActionTypeModel => { return { id, @@ -59,15 +58,6 @@ export const createActionType = ({ }; }; -const ConnectorParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, -}) => { - return null; -}; - const connectorParamsValidator = (actionParams: ActionConnectorParams): ValidationResult => { return { errors: {} }; }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index e7db228225880..91685a68a60ae 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -122,20 +122,11 @@ describe('import_rules_route', () => { clients.siemClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(400); expect(response.body).toEqual({ - errors: [ - { - error: { - message: - 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', - status_code: 409, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: + 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', + status_code: 400, }); }); @@ -145,19 +136,10 @@ describe('import_rules_route', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(500); expect(response.body).toEqual({ - errors: [ - { - error: { - message: 'Test error', - status_code: 400, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: 'Test error', + status_code: 500, }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4d86f0bec6502..9ba083ae48086 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -75,6 +75,14 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { body: `Invalid file extension ${fileExtension}`, }); } + const signalsIndex = siemClient.getSignalsIndex(); + const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, signalsIndex); + if (!indexExists) { + return siemResponse.error({ + statusCode: 400, + body: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, + }); + } const objectLimit = config.maxRuleImportExportSize; const readStream = createRulesStreamFromNdJson(objectLimit); @@ -94,166 +102,150 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { const batchParseObjects = chunkParseObjects.shift() ?? []; const newImportRuleResponse = await Promise.all( batchParseObjects.reduce>>((accum, parsedRule) => { - const importsWorkerPromise = new Promise( - async (resolve, reject) => { - if (parsedRule instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedRule.message, - }) - ); - return null; - } - const { - anomaly_threshold: anomalyThreshold, - description, - enabled, - false_positives: falsePositives, - from, - immutable, - query, - language, - machine_learning_job_id: machineLearningJobId, - output_index: outputIndex, - saved_id: savedId, - meta, - filters, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - name, - severity, - tags, - threat, - to, - type, - references, - note, - timeline_id: timelineId, - timeline_title: timelineTitle, - version, - exceptions_list, - } = parsedRule; + const importsWorkerPromise = new Promise(async resolve => { + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedRule.message, + }) + ); + return null; + } + const { + anomaly_threshold: anomalyThreshold, + description, + enabled, + false_positives: falsePositives, + from, + immutable, + query, + language, + machine_learning_job_id: machineLearningJobId, + output_index: outputIndex, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + threat, + to, + type, + references, + note, + timeline_id: timelineId, + timeline_title: timelineTitle, + version, + exceptions_list, + } = parsedRule; - try { - validateLicenseForRuleType({ - license: context.licensing.license, - ruleType: type, - }); + try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); - const signalsIndex = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists( - clusterClient.callAsCurrentUser, - signalsIndex - ); - if (!indexExists) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, - }) - ); - } - const rule = await readRules({ alertsClient, ruleId }); - if (rule == null) { - await createRules({ - alertsClient, - anomalyThreshold, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - machineLearningJobId, - outputIndex: signalsIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - actions: [], // Actions are not imported nor exported at this time - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null && request.query.overwrite) { - await patchRules({ - alertsClient, - savedObjectsClient, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - id: undefined, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - anomalyThreshold, - machineLearningJobId, - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `rule_id: "${ruleId}" already exists`, - }) - ); - } - } catch (err) { + const rule = await readRules({ alertsClient, ruleId }); + if (rule == null) { + await createRules({ + alertsClient, + anomalyThreshold, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + machineLearningJobId, + outputIndex: signalsIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + actions: [], // Actions are not imported nor exported at this time + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null && request.query.overwrite) { + await patchRules({ + alertsClient, + savedObjectsClient, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + id: undefined, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + anomalyThreshold, + machineLearningJobId, + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null) { resolve( createBulkErrorObject({ ruleId, - statusCode: 400, - message: err.message, + statusCode: 409, + message: `rule_id: "${ruleId}" already exists`, }) ); } + } catch (err) { + resolve( + createBulkErrorObject({ + ruleId, + statusCode: 400, + message: err.message, + }) + ); } - ); + }); return [...accum, importsWorkerPromise]; }, []) ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index a794b7e7c2143..d3dae0a8c8b63 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { useState, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -18,11 +19,10 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiOverlayMask, + EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; - import { getErrorMessage } from '../../../../../shared_imports'; import { @@ -30,8 +30,7 @@ import { TransformPivotConfig, REFRESH_TRANSFORM_LIST_STATE, } from '../../../../common'; -import { ToastNotificationText } from '../../../../components'; -import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; +import { useToastNotifications } from '../../../../app_dependencies'; import { useApi } from '../../../../hooks/use_api'; @@ -48,13 +47,14 @@ interface EditTransformFlyoutProps { } export const EditTransformFlyout: FC = ({ closeFlyout, config }) => { - const { overlays } = useAppDependencies(); const api = useApi(); const toastNotifications = useToastNotifications(); const [state, dispatch] = useEditTransformFlyout(config); + const [errorMessage, setErrorMessage] = useState(undefined); async function submitFormHandler() { + setErrorMessage(undefined); const requestConfig = applyFormFieldsToTransformConfig(config, state.formFields); const transformId = config.id; @@ -69,12 +69,7 @@ export const EditTransformFlyout: FC = ({ closeFlyout, closeFlyout(); refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.transformList.editTransformGenericErrorMessage', { - defaultMessage: 'An error occurred calling the API endpoint to update transforms.', - }), - text: toMountPoint(), - }); + setErrorMessage(getErrorMessage(e)); } } @@ -97,6 +92,24 @@ export const EditTransformFlyout: FC = ({ closeFlyout, }> + {errorMessage !== undefined && ( + <> + + +

{errorMessage}

+
+ + )}
diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index ece1791c66e11..c5f02863ba8a1 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -985,8 +985,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo |selectMessage|Short description of action type responsibility, that will be displayed on the select card in UI.| |validateConnector|Validation function for action connector.| |validateParams|Validation function for action params.| -|actionConnectorFields|React functional component for building UI of current action type connector.| -|actionParamsFields|React functional component for building UI of current action type params. Displayed as a part of Create Alert flyout.| +|actionConnectorFields|A lazy loaded React component for building UI of current action type connector.| +|actionParamsFields|A lazy loaded React component for building UI of current action type params. Displayed as a part of Create Alert flyout.| ## Register action type model @@ -1082,8 +1082,8 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - actionConnectorFields: ExampleConnectorFields, - actionParamsFields: ExampleParamsFields, + actionConnectorFields: lazy(() => import('./example_connector_fields')), + actionParamsFields: lazy(() => import('./example_params_fields')), }; } ``` @@ -1130,6 +1130,9 @@ const ExampleConnectorFields: React.FunctionComponent ); }; + +// Export as default in order to support lazy loading +export {ExampleConnectorFields as default}; ``` 3. Define action type params fields using the property of `ActionTypeModel` `actionParamsFields`: @@ -1175,6 +1178,9 @@ const ExampleParamsFields: React.FunctionComponent ); }; + +// Export as default in order to support lazy loading +export {ExampleParamsFields as default}; ``` 4. Extend registration code with the new action type register in the file `x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts` diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 0593940a0d105..63860e062c8da 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -3,8 +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 React from 'react'; -import { Switch, Route, Redirect, HashRouter } from 'react-router-dom'; +import React, { lazy, Suspense } from 'react'; +import { Switch, Route, Redirect, HashRouter, RouteComponentProps } from 'react-router-dom'; import { ChromeStart, DocLinksStart, @@ -15,17 +15,21 @@ import { ChromeBreadcrumb, CoreStart, } from 'kibana/public'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BASE_PATH, Section, routeToAlertDetails } from './constants'; -import { TriggersActionsUIHome } from './home'; import { AppContextProvider, useAppDependencies } from './app_context'; import { hasShowAlertsCapability } from './lib/capabilities'; import { ActionTypeModel, AlertTypeModel } from '../types'; import { TypeRegistry } from './type_registry'; -import { AlertDetailsRouteWithApi as AlertDetailsRoute } from './sections/alert_details/components/alert_details_route'; import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { PluginStartContract as AlertingStart } from '../../../alerting/public'; +const TriggersActionsUIHome = lazy(async () => import('./home')); +const AlertDetailsRoute = lazy(() => + import('./sections/alert_details/components/alert_details_route') +); + export interface AppDeps { dataPlugin: DataPublicPluginStart; charts: ChartsPluginStart; @@ -62,9 +66,32 @@ export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) = const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; return ( - - {canShowAlerts && } + + {canShowAlerts && ( + + )} ); }; + +function suspendedRouteComponent( + RouteComponent: React.ComponentType> +) { + return (props: RouteComponentProps) => ( + + + + + + } + > + + + ); +} 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 deleted file mode 100644 index dff697297f3e4..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ /dev/null @@ -1,609 +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, { Fragment, useState, useEffect } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFieldText, - EuiFlexItem, - EuiFlexGroup, - EuiFieldNumber, - EuiFieldPassword, - EuiComboBox, - EuiTextArea, - EuiButtonEmpty, - EuiSwitch, - EuiFormRow, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { EmailActionParams, EmailActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - const mailformat = /^[^@\s]+@[^@\s]+$/; - return { - id: '.email', - iconClass: 'email', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', - { - defaultMessage: 'Send email from your server.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle', - { - defaultMessage: 'Send to email', - } - ), - validateConnector: (action: EmailActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - from: new Array(), - port: new Array(), - host: new Array(), - user: new Array(), - password: new Array(), - }; - validationResult.errors = errors; - if (!action.config.from) { - errors.from.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', - { - defaultMessage: 'Sender is required.', - } - ) - ); - } - if (action.config.from && !action.config.from.trim().match(mailformat)) { - errors.from.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', - { - defaultMessage: 'Sender is not a valid email address.', - } - ) - ); - } - if (!action.config.port) { - errors.port.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', - { - defaultMessage: 'Port is required.', - } - ) - ); - } - if (!action.config.host) { - errors.host.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', - { - defaultMessage: 'Host is required.', - } - ) - ); - } - if (action.secrets.user && !action.secrets.password) { - errors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); - } - if (!action.secrets.user && action.secrets.password) { - errors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: EmailActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - to: new Array(), - cc: new Array(), - bcc: new Array(), - message: new Array(), - subject: new Array(), - }; - validationResult.errors = errors; - if ( - (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && - (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && - (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) - ) { - const errorText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', - { - defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', - } - ); - errors.to.push(errorText); - errors.cc.push(errorText); - errors.bcc.push(errorText); - } - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - if (!actionParams.subject?.length) { - errors.subject.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', - { - defaultMessage: 'Subject is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: EmailActionConnectorFields, - actionParamsFields: EmailParamsFields, - }; -} - -const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { - const { from, host, port, secure } = action.config; - const { user, password } = action.secrets; - - return ( - - - - 0 && from !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', - { - defaultMessage: 'Sender', - } - )} - > - 0 && from !== undefined} - name="from" - value={from || ''} - data-test-subj="emailFromInput" - onChange={e => { - editActionConfig('from', e.target.value); - }} - onBlur={() => { - if (!from) { - editActionConfig('from', ''); - } - }} - /> - - - - - - 0 && host !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', - { - defaultMessage: 'Host', - } - )} - > - 0 && host !== undefined} - name="host" - value={host || ''} - data-test-subj="emailHostInput" - onChange={e => { - editActionConfig('host', e.target.value); - }} - onBlur={() => { - if (!host) { - editActionConfig('host', ''); - } - }} - /> - - - - - - 0 && port !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', - { - defaultMessage: 'Port', - } - )} - > - 0 && port !== undefined} - fullWidth - name="port" - value={port || ''} - data-test-subj="emailPortInput" - onChange={e => { - editActionConfig('port', parseInt(e.target.value, 10)); - }} - onBlur={() => { - if (!port) { - editActionConfig('port', 0); - } - }} - /> - - - - - - { - editActionConfig('secure', e.target.checked); - }} - /> - - - - - - - - - 0} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', - { - defaultMessage: 'Username', - } - )} - > - 0} - name="user" - value={user || ''} - data-test-subj="emailUserInput" - onChange={e => { - editActionSecrets('user', nullableString(e.target.value)); - }} - /> - - - - 0} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', - { - defaultMessage: 'Password', - } - )} - > - 0} - name="password" - value={password || ''} - data-test-subj="emailPasswordInput" - onChange={e => { - editActionSecrets('password', nullableString(e.target.value)); - }} - /> - - - - - ); -}; - -const EmailParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, - messageVariables, - defaultMessage, -}) => { - const { to, cc, bcc, subject, message } = actionParams; - const toOptions = to ? to.map((label: string) => ({ label })) : []; - const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; - const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; - const [addCC, setAddCC] = useState(false); - const [addBCC, setAddBCC] = useState(false); - - useEffect(() => { - if (!message && defaultMessage && defaultMessage.length > 0) { - editAction('message', defaultMessage, index); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction( - paramsProperty, - ((actionParams as any)[paramsProperty] ?? '').concat(` {{${variable}}}`), - index - ); - }; - - return ( - - 0 && to !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', - { - defaultMessage: 'To', - } - )} - labelAppend={ - - - {!addCC ? ( - setAddCC(true)}> - - - ) : null} - {!addBCC ? ( - setAddBCC(true)}> - - - ) : null} - - - } - > - 0 && to !== undefined} - fullWidth - data-test-subj="toEmailAddressInput" - selectedOptions={toOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...toOptions, { label: searchValue }]; - editAction( - 'to', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'to', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!to) { - editAction('to', [], index); - } - }} - /> - - {addCC ? ( - 0 && cc !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', - { - defaultMessage: 'Cc', - } - )} - > - 0 && cc !== undefined} - fullWidth - data-test-subj="ccEmailAddressInput" - selectedOptions={ccOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...ccOptions, { label: searchValue }]; - editAction( - 'cc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'cc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!cc) { - editAction('cc', [], index); - } - }} - /> - - ) : null} - {addBCC ? ( - 0 && bcc !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', - { - defaultMessage: 'Bcc', - } - )} - > - 0 && bcc !== undefined} - fullWidth - data-test-subj="bccEmailAddressInput" - selectedOptions={bccOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...bccOptions, { label: searchValue }]; - editAction( - 'bcc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'bcc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!bcc) { - editAction('bcc', [], index); - } - }} - /> - - ) : null} - 0 && subject !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', - { - defaultMessage: 'Subject', - } - )} - labelAppend={ - - onSelectMessageVariable('subject', variable) - } - paramsProperty="subject" - /> - } - > - 0 && subject !== undefined} - name="subject" - data-test-subj="emailSubjectInput" - value={subject || ''} - onChange={e => { - editAction('subject', e.target.value, index); - }} - onBlur={() => { - if (!subject) { - editAction('subject', '', index); - } - }} - /> - - 0 && message !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel', - { - defaultMessage: 'Message', - } - )} - labelAppend={ - - onSelectMessageVariable('message', variable) - } - paramsProperty="message" - /> - } - > - 0 && message !== undefined} - value={message || ''} - name="message" - data-test-subj="emailMessageInput" - onChange={e => { - editAction('message', e.target.value, index); - }} - onBlur={() => { - if (!message) { - editAction('message', '', index); - } - }} - /> - - - ); -}; - -// if the string == null or is empty, return null, else return string -function nullableString(str: string | null | undefined) { - if (str == null || str.trim() === '') return null; - return str; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx similarity index 62% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index af9e34071fd09..e823e848f52c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -3,12 +3,10 @@ * 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, { FunctionComponent } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { EmailActionParams, EmailActionConnector } from './types'; +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '../index'; +import { ActionTypeModel } from '../../../../types'; +import { EmailActionConnector } from '../types'; const ACTION_TYPE_ID = '.email'; let actionTypeModel: ActionTypeModel; @@ -206,80 +204,3 @@ describe('action params validation', () => { }); }); }); - -describe('EmailActionConnectorFields renders', () => { - test('all connector fields is rendered', () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: { - from: 'test@test.com', - }, - } as EmailActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> - ); - expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="emailFromInput"]') - .first() - .prop('value') - ).toBe('test@test.com'); - expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('EmailParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - cc: [], - bcc: [], - to: ['test@test.com'], - subject: 'test', - message: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="toEmailAddressInput"]') - .first() - .prop('selectedOptions') - ).toStrictEqual([{ label: 'test@test.com' }]); - expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx new file mode 100644 index 0000000000000..abb102c04b054 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -0,0 +1,150 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { EmailActionParams, EmailActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + const mailformat = /^[^@\s]+@[^@\s]+$/; + return { + id: '.email', + iconClass: 'email', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', + { + defaultMessage: 'Send email from your server.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle', + { + defaultMessage: 'Send to email', + } + ), + validateConnector: (action: EmailActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + from: new Array(), + port: new Array(), + host: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.from) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } + ) + ); + } + if (action.config.from && !action.config.from.trim().match(mailformat)) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } + ) + ); + } + if (!action.config.port) { + errors.port.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } + ) + ); + } + if (!action.config.host) { + errors.host.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } + ) + ); + } + if (action.secrets.user && !action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } + ) + ); + } + if (!action.secrets.user && action.secrets.password) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: EmailActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + to: new Array(), + cc: new Array(), + bcc: new Array(), + message: new Array(), + subject: new Array(), + }; + validationResult.errors = errors; + if ( + (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && + (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && + (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) + ) { + const errorText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } + ); + errors.to.push(errorText); + errors.cc.push(errorText); + errors.bcc.push(errorText); + } + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + if (!actionParams.subject?.length) { + errors.subject.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./email_connector')), + actionParamsFields: lazy(() => import('./email_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx new file mode 100644 index 0000000000000..67514e815bc49 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EmailActionConnector } from '../types'; +import EmailActionConnectorFields from './email_connector'; +import { DocLinksStart } from 'kibana/public'; + +describe('EmailActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as EmailActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + /> + ); + expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="emailFromInput"]') + .first() + .prop('value') + ).toBe('test@test.com'); + expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx new file mode 100644 index 0000000000000..4ef4c8a4d8617 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -0,0 +1,209 @@ +/* + * 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 { + EuiFieldText, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiFieldPassword, + EuiSwitch, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { EmailActionConnector } from '../types'; + +export const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { + const { from, host, port, secure } = action.config; + const { user, password } = action.secrets; + + return ( + + + + 0 && from !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', + { + defaultMessage: 'Sender', + } + )} + > + 0 && from !== undefined} + name="from" + value={from || ''} + data-test-subj="emailFromInput" + onChange={e => { + editActionConfig('from', e.target.value); + }} + onBlur={() => { + if (!from) { + editActionConfig('from', ''); + } + }} + /> + + + + + + 0 && host !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', + { + defaultMessage: 'Host', + } + )} + > + 0 && host !== undefined} + name="host" + value={host || ''} + data-test-subj="emailHostInput" + onChange={e => { + editActionConfig('host', e.target.value); + }} + onBlur={() => { + if (!host) { + editActionConfig('host', ''); + } + }} + /> + + + + + + 0 && port !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', + { + defaultMessage: 'Port', + } + )} + > + 0 && port !== undefined} + fullWidth + name="port" + value={port || ''} + data-test-subj="emailPortInput" + onChange={e => { + editActionConfig('port', parseInt(e.target.value, 10)); + }} + onBlur={() => { + if (!port) { + editActionConfig('port', 0); + } + }} + /> + + + + + + { + editActionConfig('secure', e.target.checked); + }} + /> + + + + + + + + + 0} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0} + name="user" + value={user || ''} + data-test-subj="emailUserInput" + onChange={e => { + editActionSecrets('user', nullableString(e.target.value)); + }} + /> + + + + 0} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0} + name="password" + value={password || ''} + data-test-subj="emailPasswordInput" + onChange={e => { + editActionSecrets('password', nullableString(e.target.value)); + }} + /> + + + + + ); +}; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} + +// eslint-disable-next-line import/no-default-export +export { EmailActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx new file mode 100644 index 0000000000000..a2b5ccf988afb --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import EmailParamsFields from './email_params'; + +describe('EmailParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + cc: [], + bcc: [], + to: ['test@test.com'], + subject: 'test', + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="toEmailAddressInput"]') + .first() + .prop('selectedOptions') + ).toStrictEqual([{ label: 'test@test.com' }]); + expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx new file mode 100644 index 0000000000000..13e791f1069e3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -0,0 +1,267 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFieldText, EuiComboBox, EuiTextArea, EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { EmailActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +export const EmailParamsFields = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, +}: ActionParamsProps) => { + const { to, cc, bcc, subject, message } = actionParams; + const toOptions = to ? to.map((label: string) => ({ label })) : []; + const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; + const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; + const [addCC, setAddCC] = useState(false); + const [addBCC, setAddBCC] = useState(false); + + useEffect(() => { + if (!message && defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction( + paramsProperty, + ((actionParams as any)[paramsProperty] ?? '').concat(` {{${variable}}}`), + index + ); + }; + + return ( + + 0 && to !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', + { + defaultMessage: 'To', + } + )} + labelAppend={ + + + {!addCC ? ( + setAddCC(true)}> + + + ) : null} + {!addBCC ? ( + setAddBCC(true)}> + + + ) : null} + + + } + > + 0 && to !== undefined} + fullWidth + data-test-subj="toEmailAddressInput" + selectedOptions={toOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...toOptions, { label: searchValue }]; + editAction( + 'to', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'to', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!to) { + editAction('to', [], index); + } + }} + /> + + {addCC ? ( + 0 && cc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', + { + defaultMessage: 'Cc', + } + )} + > + 0 && cc !== undefined} + fullWidth + data-test-subj="ccEmailAddressInput" + selectedOptions={ccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...ccOptions, { label: searchValue }]; + editAction( + 'cc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'cc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!cc) { + editAction('cc', [], index); + } + }} + /> + + ) : null} + {addBCC ? ( + 0 && bcc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', + { + defaultMessage: 'Bcc', + } + )} + > + 0 && bcc !== undefined} + fullWidth + data-test-subj="bccEmailAddressInput" + selectedOptions={bccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...bccOptions, { label: searchValue }]; + editAction( + 'bcc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'bcc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!bcc) { + editAction('bcc', [], index); + } + }} + /> + + ) : null} + 0 && subject !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', + { + defaultMessage: 'Subject', + } + )} + labelAppend={ + + onSelectMessageVariable('subject', variable) + } + paramsProperty="subject" + /> + } + > + 0 && subject !== undefined} + name="subject" + data-test-subj="emailSubjectInput" + value={subject || ''} + onChange={e => { + editAction('subject', e.target.value, index); + }} + onBlur={() => { + if (!subject) { + editAction('subject', '', index); + } + }} + /> + + 0 && message !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel', + { + defaultMessage: 'Message', + } + )} + labelAppend={ + + onSelectMessageVariable('message', variable) + } + paramsProperty="message" + /> + } + > + 0 && message !== undefined} + value={message || ''} + name="message" + data-test-subj="emailMessageInput" + onChange={e => { + editAction('message', e.target.value, index); + }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { EmailParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/index.ts new file mode 100644 index 0000000000000..e0dd24a44aa8f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/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 { getActionType as getEmailActionType } from './email'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx deleted file mode 100644 index 04dc7b484ed48..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx +++ /dev/null @@ -1,240 +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, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { IndexActionParams, EsIndexActionConnector } from './types'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; -jest.mock('../../../common/index_controls', () => ({ - firstFieldOption: jest.fn(), - getFields: jest.fn(), - getIndexOptions: jest.fn(), - getIndexPatterns: jest.fn(), -})); - -const ACTION_TYPE_ID = '.index'; -let actionTypeModel: ActionTypeModel; -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, - }, - actionTypeRegistry: actionTypeRegistry as any, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type .index is registered', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('indexOpen'); - }); -}); - -describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test_es_index', - refresh: false, - executionTimeField: '1', - }, - } as EsIndexActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - index: [], - }, - }); - }); -}); - -describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test_es_index', - }, - } as EsIndexActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - index: [], - }, - }); - }); -}); - -describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - documents: ['test'], - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: {}, - }); - - const emptyActionParams = {}; - - expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ - errors: {}, - }); - }); -}); - -describe('IndexActionConnectorFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - - const { getIndexPatterns } = jest.requireMock('../../../common/index_controls'); - getIndexPatterns.mockResolvedValueOnce([ - { - id: 'indexPattern1', - attributes: { - title: 'indexPattern1', - }, - }, - { - id: 'indexPattern2', - attributes: { - title: 'indexPattern2', - }, - }, - ]); - const { getFields } = jest.requireMock('../../../common/index_controls'); - getFields.mockResolvedValueOnce([ - { - type: 'date', - name: 'test1', - }, - { - type: 'text', - name: 'test2', - }, - ]); - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test', - refresh: false, - executionTimeField: 'test1', - }, - } as EsIndexActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - http={deps!.http} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); - - const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); - expect(indexSearchBoxValue.first().props().value).toEqual(''); - - const indexComboBox = wrapper.find('#indexConnectorSelectSearchBox'); - indexComboBox.first().simulate('click'); - const event = { target: { value: 'indexPattern1' } }; - indexComboBox - .find('input') - .first() - .simulate('change', event); - - const indexSearchBoxValueBeforeEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueBeforeEnterData.first().props().value).toEqual('indexPattern1'); - - const indexComboBoxClear = wrapper.find('[data-test-subj="comboBoxClearButton"]'); - indexComboBoxClear.first().simulate('click'); - - const indexSearchBoxValueAfterEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueAfterEnterData.first().props().value).toEqual('indexPattern1'); - }); -}); - -describe('IndexParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - documents: [{ test: 123 }], - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect( - wrapper - .find('[data-test-subj="actionIndexDoc"]') - .first() - .prop('value') - ).toBe(`{ - "test": 123 -}`); - expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx new file mode 100644 index 0000000000000..417a9e09086a2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '../index'; +import { ActionTypeModel } from '../../../../types'; +import { EsIndexActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.index'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type .index is registered', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('indexOpen'); + }); +}); + +describe('index connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + refresh: false, + executionTimeField: '1', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + +describe('index connector validation with minimal config', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + documents: ['test'], + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + + const emptyActionParams = {}; + + expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ + errors: {}, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx new file mode 100644 index 0000000000000..3ee663a5fc8a0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { EsIndexActionConnector, IndexActionParams } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.index', + iconClass: 'indexOpen', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', + { + defaultMessage: 'Index data into Elasticsearch.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle', + { + defaultMessage: 'Index data', + } + ), + validateConnector: (action: EsIndexActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + index: new Array(), + }; + validationResult.errors = errors; + if (!action.config.index) { + errors.index.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./es_index_connector')), + actionParamsFields: lazy(() => import('./es_index_params')), + validateParams: (): ValidationResult => { + return { errors: {} }; + }, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx new file mode 100644 index 0000000000000..b0f21afeaa96c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -0,0 +1,126 @@ +/* + * 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 { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { EsIndexActionConnector } from '../types'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import IndexActionConnectorFields from './es_index_connector'; +import { TypeRegistry } from '../../../type_registry'; +import { DocLinksStart } from 'kibana/public'; + +jest.mock('../../../../common/index_controls', () => ({ + firstFieldOption: jest.fn(), + getFields: jest.fn(), + getIndexOptions: jest.fn(), + getIndexPatterns: jest.fn(), +})); + +describe('IndexActionConnectorFields renders', () => { + test('all connector fields is rendered', async () => { + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + const deps = { + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + actionTypeRegistry: {} as TypeRegistry, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + + const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); + getIndexPatterns.mockResolvedValueOnce([ + { + id: 'indexPattern1', + attributes: { + title: 'indexPattern1', + }, + }, + { + id: 'indexPattern2', + attributes: { + title: 'indexPattern2', + }, + }, + ]); + const { getFields } = jest.requireMock('../../../../common/index_controls'); + getFields.mockResolvedValueOnce([ + { + type: 'date', + name: 'test1', + }, + { + type: 'text', + name: 'test2', + }, + ]); + + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test', + refresh: false, + executionTimeField: 'test1', + }, + } as EsIndexActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + http={deps!.http} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); + + const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(indexSearchBoxValue.first().props().value).toEqual(''); + + const indexComboBox = wrapper.find('#indexConnectorSelectSearchBox'); + indexComboBox.first().simulate('click'); + const event = { target: { value: 'indexPattern1' } }; + indexComboBox + .find('input') + .first() + .simulate('change', event); + + const indexSearchBoxValueBeforeEnterData = wrapper.find( + '[data-test-subj="comboBoxSearchInput"]' + ); + expect(indexSearchBoxValueBeforeEnterData.first().props().value).toEqual('indexPattern1'); + + const indexComboBoxClear = wrapper.find('[data-test-subj="comboBoxClearButton"]'); + indexComboBoxClear.first().simulate('click'); + + const indexSearchBoxValueAfterEnterData = wrapper.find( + '[data-test-subj="comboBoxSearchInput"]' + ); + expect(indexSearchBoxValueAfterEnterData.first().props().value).toEqual('indexPattern1'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx similarity index 66% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index 861d6ad7284c2..9cd3a18545345 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -3,12 +3,11 @@ * 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 React, { useState, useEffect } from 'react'; import { EuiFormRow, EuiSwitch, EuiSpacer, - EuiCodeEditor, EuiComboBox, EuiComboBoxOptionOption, EuiSelect, @@ -17,64 +16,15 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { useXJsonMode } from '../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { IndexActionParams, EsIndexActionConnector } from './types'; -import { getTimeFieldOptions } from '../../../common/lib/get_time_options'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { EsIndexActionConnector } from '.././types'; +import { getTimeFieldOptions } from '../../../../common/lib/get_time_options'; import { firstFieldOption, getFields, getIndexOptions, getIndexPatterns, -} from '../../../common/index_controls'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.index', - iconClass: 'indexOpen', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', - { - defaultMessage: 'Index data into Elasticsearch.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle', - { - defaultMessage: 'Index data', - } - ), - validateConnector: (action: EsIndexActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - index: new Array(), - }; - validationResult.errors = errors; - if (!action.config.index) { - errors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: IndexActionConnectorFields, - actionParamsFields: IndexParamsFields, - validateParams: (): ValidationResult => { - return { errors: {} }; - }, - }; -} +} from '../../../../common/index_controls'; const IndexActionConnectorFields: React.FunctionComponent> = ({ - actionParams, - index, - editAction, - messageVariables, -}) => { - const { documents } = actionParams; - const { xJsonMode, convertToJson, setXJson, xJson } = useXJsonMode( - documents && documents.length > 0 ? documents[0] : null - ); - const onSelectMessageVariable = (variable: string) => { - const value = (xJson ?? '').concat(` {{${variable}}}`); - setXJson(value); - // Keep the documents in sync with the editor content - onDocumentsChange(convertToJson(value)); - }; - - function onDocumentsChange(updatedDocuments: string) { - try { - const documentsJSON = JSON.parse(updatedDocuments); - editAction('documents', [documentsJSON], index); - // eslint-disable-next-line no-empty - } catch (e) {} - } - return ( - - onSelectMessageVariable(variable)} - paramsProperty="documents" - /> - } - > - { - setXJson(xjson); - // Keep the documents in sync with the editor content - onDocumentsChange(convertToJson(xjson)); - }} - /> - - - ); -}; - // if the string == null or is empty, return null, else return string function nullableString(str: string | null | undefined) { if (str == null || str.trim() === '') return null; return str; } + +// eslint-disable-next-line import/no-default-export +export { IndexActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx new file mode 100644 index 0000000000000..5f05a56a228e2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import ParamsFields from './es_index_params'; + +describe('IndexParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + documents: [{ test: 123 }], + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect( + wrapper + .find('[data-test-subj="actionIndexDoc"]') + .first() + .prop('value') + ).toBe(`{ + "test": 123 +}`); + expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx new file mode 100644 index 0000000000000..0b095cdc26984 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useXJsonMode } from '../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; +import { ActionParamsProps } from '../../../../types'; +import { IndexActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; + +export const IndexParamsFields = ({ + actionParams, + index, + editAction, + messageVariables, +}: ActionParamsProps) => { + const { documents } = actionParams; + const { xJsonMode, convertToJson, setXJson, xJson } = useXJsonMode( + documents && documents.length > 0 ? documents[0] : null + ); + const onSelectMessageVariable = (variable: string) => { + const value = (xJson ?? '').concat(` {{${variable}}}`); + setXJson(value); + // Keep the documents in sync with the editor content + onDocumentsChange(convertToJson(value)); + }; + + function onDocumentsChange(updatedDocuments: string) { + try { + const documentsJSON = JSON.parse(updatedDocuments); + editAction('documents', [documentsJSON], index); + // eslint-disable-next-line no-empty + } catch (e) {} + } + return ( + + onSelectMessageVariable(variable)} + paramsProperty="documents" + /> + } + > + { + setXJson(xjson); + // Keep the documents in sync with the editor content + onDocumentsChange(convertToJson(xjson)); + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { IndexParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/index.ts new file mode 100644 index 0000000000000..6a2ebd9c4bc71 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/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 { getActionType as getIndexActionType } from './es_index'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 6ffd9b2c9ffde..8f49fa46dd54e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getActionType as getServerLogActionType } from './server_log'; -import { getActionType as getSlackActionType } from './slack'; -import { getActionType as getEmailActionType } from './email'; -import { getActionType as getIndexActionType } from './es_index'; -import { getActionType as getPagerDutyActionType } from './pagerduty'; -import { getActionType as getWebhookActionType } from './webhook'; +import { getServerLogActionType } from './server_log'; +import { getSlackActionType } from './slack'; +import { getEmailActionType } from './email'; +import { getIndexActionType } from './es_index'; +import { getPagerDutyActionType } from './pagerduty'; +import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx deleted file mode 100644 index f628457dc5162..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx +++ /dev/null @@ -1,200 +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, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { - PagerDutyActionParams, - EventActionOptions, - SeverityActionOptions, - PagerDutyActionConnector, -} from './types'; - -const ACTION_TYPE_ID = '.pagerduty'; -let actionTypeModel: ActionTypeModel; -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('test-file-stub'); - }); -}); - -describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - routingKey: 'test', - }, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: [], - }, - }); - - delete actionConnector.config.apiUrl; - actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: ['A routing key is required.'], - }, - }); - }); -}); - -describe('pagerduty action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - eventAction: 'trigger', - dedupKey: 'test', - summary: '2323', - source: 'source', - severity: 'critical', - timestamp: new Date().toISOString(), - component: 'test', - group: 'group', - class: 'test class', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - summary: [], - timestamp: [], - }, - }); - }); -}); - -describe('PagerDutyActionConnectorFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - routingKey: 'test', - }, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - docLinks={deps!.docLinks} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="pagerdutyApiUrlInput"]') - .first() - .prop('value') - ).toBe('http:\\test'); - expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('PagerDutyParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - eventAction: EventActionOptions.TRIGGER, - dedupKey: 'test', - summary: '2323', - source: 'source', - severity: SeverityActionOptions.CRITICAL, - timestamp: new Date().toISOString(), - component: 'test', - group: 'group', - class: 'test class', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="severitySelect"]') - .first() - .prop('value') - ).toStrictEqual('critical'); - expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="pagerdutySummaryInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/index.ts new file mode 100644 index 0000000000000..9128ec81391ab --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/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 { getActionType as getPagerDutyActionType } from './pagerduty'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.svg b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.svg similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.svg rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.svg diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx new file mode 100644 index 0000000000000..ba7eb598c120d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -0,0 +1,99 @@ +/* + * 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 { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { PagerDutyActionConnector } from '.././types'; + +const ACTION_TYPE_ID = '.pagerduty'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('test-file-stub'); + }); +}); + +describe('pagerduty connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + + delete actionConnector.config.apiUrl; + actionConnector.secrets.routingKey = 'test1'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: ['A routing key is required.'], + }, + }); + }); +}); + +describe('pagerduty action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + summary: [], + timestamp: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx new file mode 100644 index 0000000000000..5e29fca397180 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { PagerDutyActionParams, PagerDutyActionConnector } from '.././types'; +import pagerDutySvg from './pagerduty.svg'; +import { hasMustacheTokens } from '../../../lib/has_mustache_tokens'; + +export function getActionType(): ActionTypeModel { + return { + id: '.pagerduty', + iconClass: pagerDutySvg, + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', + { + defaultMessage: 'Send an event in PagerDuty.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle', + { + defaultMessage: 'Send to PagerDuty', + } + ), + validateConnector: (action: PagerDutyActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + routingKey: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.routingKey) { + errors.routingKey.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'A routing key is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + summary: new Array(), + timestamp: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.summary?.length) { + errors.summary.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } + ) + ); + } + if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { + if (isNaN(Date.parse(actionParams.timestamp))) { + const { nowShortFormat, nowLongFormat } = getValidTimestampExamples(); + errors.timestamp.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.invalidTimestamp', + { + defaultMessage: + 'Timestamp must be a valid date, such as {nowShortFormat} or {nowLongFormat}.', + values: { + nowShortFormat, + nowLongFormat, + }, + } + ) + ); + } + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./pagerduty_connectors')), + actionParamsFields: lazy(() => import('./pagerduty_params')), + }; +} + +function getValidTimestampExamples() { + const now = moment(); + return { + nowShortFormat: now.format('YYYY-MM-DD'), + nowLongFormat: now.format('YYYY-MM-DD h:mm:ss'), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx new file mode 100644 index 0000000000000..3f3fba1599bd2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { PagerDutyActionConnector } from '.././types'; +import PagerDutyActionConnectorFields from './pagerduty_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('PagerDutyActionConnectorFields renders', () => { + test('all connector fields is rendered', async () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="pagerdutyApiUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx new file mode 100644 index 0000000000000..48da3f1778b48 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { PagerDutyActionConnector } from '.././types'; + +const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets, docLinks }) => { + const { apiUrl } = action.config; + const { routingKey } = action.secrets; + return ( + + + ) => { + editActionConfig('apiUrl', e.target.value); + }} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + } + error={errors.routingKey} + isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', + { + defaultMessage: 'Integration key', + } + )} + > + 0 && routingKey !== undefined} + name="routingKey" + value={routingKey || ''} + data-test-subj="pagerdutyRoutingKeyInput" + onChange={(e: React.ChangeEvent) => { + editActionSecrets('routingKey', e.target.value); + }} + onBlur={() => { + if (!routingKey) { + editActionSecrets('routingKey', ''); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { PagerDutyActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx new file mode 100644 index 0000000000000..d1b32f545c335 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EventActionOptions, SeverityActionOptions } from '.././types'; +import PagerDutyParamsFields from './pagerduty_params'; + +describe('PagerDutyParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + eventAction: EventActionOptions.TRIGGER, + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: SeverityActionOptions.CRITICAL, + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="severitySelect"]') + .first() + .prop('value') + ).toStrictEqual('critical'); + expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="pagerdutySummaryInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx similarity index 67% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 5ad1f2fffecce..590eba5dad936 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -4,178 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiLink, -} from '@elastic/eui'; +import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { PagerDutyActionParams, PagerDutyActionConnector } from './types'; -import pagerDutySvg from './pagerduty.svg'; -import { AddMessageVariables } from '../add_message_variables'; -import { hasMustacheTokens } from '../../lib/has_mustache_tokens'; - -export function getActionType(): ActionTypeModel { - return { - id: '.pagerduty', - iconClass: pagerDutySvg, - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', - { - defaultMessage: 'Send an event in PagerDuty.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle', - { - defaultMessage: 'Send to PagerDuty', - } - ), - validateConnector: (action: PagerDutyActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - routingKey: new Array(), - }; - validationResult.errors = errors; - if (!action.secrets.routingKey) { - errors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'A routing key is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - summary: new Array(), - timestamp: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.summary?.length) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); - } - if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { - if (isNaN(Date.parse(actionParams.timestamp))) { - const { nowShortFormat, nowLongFormat } = getValidTimestampExamples(); - errors.timestamp.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.invalidTimestamp', - { - defaultMessage: - 'Timestamp must be a valid date, such as {nowShortFormat} or {nowLongFormat}.', - values: { - nowShortFormat, - nowLongFormat, - }, - } - ) - ); - } - } - return validationResult; - }, - actionConnectorFields: PagerDutyActionConnectorFields, - actionParamsFields: PagerDutyParamsFields, - }; -} - -const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets, docLinks }) => { - const { apiUrl } = action.config; - const { routingKey } = action.secrets; - return ( - - - ) => { - editActionConfig('apiUrl', e.target.value); - }} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - } - error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', - { - defaultMessage: 'Integration key', - } - )} - > - 0 && routingKey !== undefined} - name="routingKey" - value={routingKey || ''} - data-test-subj="pagerdutyRoutingKeyInput" - onChange={(e: React.ChangeEvent) => { - editActionSecrets('routingKey', e.target.value); - }} - onBlur={() => { - if (!routingKey) { - editActionSecrets('routingKey', ''); - } - }} - /> - - - ); -}; +import { ActionParamsProps } from '../../../../types'; +import { PagerDutyActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; const PagerDutyParamsFields: React.FunctionComponent> = ({ actionParams, @@ -561,10 +394,5 @@ const PagerDutyParamsFields: React.FunctionComponent { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logsApp'); - }); -}); - -describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.server-log', - name: 'server-log', - config: {}, - } as ActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: {}, - }); - }); -}); - -describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - message: 'test message', - level: 'trace', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { message: [] }, - }); - }); -}); - -describe('ServerLogParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - level: ServerLogLevelOptions.TRACE, - message: 'test', - }; - const wrapper = mountWithIntl( - {}} - index={0} - defaultMessage={'test default message'} - /> - ); - expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="loggingLevelSelect"]') - .first() - .prop('value') - ).toStrictEqual('trace'); - expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); - }); - - test('level param field is rendered with default value if not selected', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - message: 'test message', - level: ServerLogLevelOptions.INFO, - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="loggingLevelSelect"]') - .first() - .prop('value') - ).toStrictEqual('info'); - expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); - }); - - test('params validation fails when message is not valid', () => { - const actionParams = { - message: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - message: ['Message is required.'], - }, - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/index.ts new file mode 100644 index 0000000000000..f85c7460d2ece --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/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 { getActionType as getServerLogActionType } from './server_log'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx new file mode 100644 index 0000000000000..3bb5ea68a3040 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx @@ -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 { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel, ActionConnector } from '../../../../types'; + +const ACTION_TYPE_ID = '.server-log'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logsApp'); + }); +}); + +describe('server-log connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.server-log', + name: 'server-log', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'test message', + level: 'trace', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx new file mode 100644 index 0000000000000..390ccf6a494e9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { ServerLogActionParams } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.server-log', + iconClass: 'logsApp', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', + { + defaultMessage: 'Add a message to a Kibana log.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.actionTypeTitle', + { + defaultMessage: 'Send to Server log', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (actionParams: ServerLogActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: lazy(() => import('./server_log_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx new file mode 100644 index 0000000000000..d2e1d1e4500bc --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ServerLogLevelOptions } from '.././types'; +import ServerLogParamsFields from './server_log_params'; + +describe('ServerLogParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + level: ServerLogLevelOptions.TRACE, + message: 'test', + }; + const wrapper = mountWithIntl( + {}} + index={0} + defaultMessage={'test default message'} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('trace'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('level param field is rendered with default value if not selected', () => { + const actionParams = { + message: 'test message', + level: ServerLogLevelOptions.INFO, + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('info'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); +}); 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/server_log_params.tsx similarity index 67% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.tsx index a4c83ce76f04e..64d39e238be76 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/server_log_params.tsx @@ -6,51 +6,9 @@ import React, { Fragment, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui'; -import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types'; -import { ServerLogActionParams } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.server-log', - iconClass: 'logsApp', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', - { - defaultMessage: 'Add a message to a Kibana log.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.actionTypeTitle', - { - defaultMessage: 'Send to Server log', - } - ), - validateConnector: (): ValidationResult => { - return { errors: {} }; - }, - validateParams: (actionParams: ServerLogActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - message: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: null, - actionParamsFields: ServerLogParamsFields, - }; -} +import { ActionParamsProps } from '../../../../types'; +import { ServerLogActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; export const ServerLogParamsFields: React.FunctionComponent ); }; + +// eslint-disable-next-line import/no-default-export +export { ServerLogParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx deleted file mode 100644 index a2865b27bc06c..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx +++ /dev/null @@ -1,166 +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, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { SlackActionParams, SlackActionConnector } from './types'; - -const ACTION_TYPE_ID = '.slack'; -let actionTypeModel: ActionTypeModel; - -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logoSlack'); - }); -}); - -describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - webhookUrl: 'http:\\test', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - webhookUrl: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - webhookUrl: ['Webhook URL is required.'], - }, - }); - }); -}); - -describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { - const actionParams = { - message: 'message {test}', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { message: [] }, - }); - }); -}); - -describe('SlackActionFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - webhookUrl: 'http:\\test', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - docLinks={deps!.docLinks} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="slackWebhookUrlInput"]') - .first() - .prop('value') - ).toBe('http:\\test'); - }); -}); - -describe('SlackParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - message: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="slackMessageTextArea"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="slackMessageTextArea"]') - .first() - .prop('value') - ).toStrictEqual('test message'); - }); - - test('params validation fails when message is not valid', () => { - const actionParams = { - message: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - message: ['Message is required.'], - }, - }); - }); -}); 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 deleted file mode 100644 index 03f7a2f492d54..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ /dev/null @@ -1,188 +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, { Fragment, useEffect } from 'react'; -import { EuiFieldText, EuiTextArea, EuiFormRow, EuiLink } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { SlackActionParams, SlackActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.slack', - iconClass: 'logoSlack', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', - { - defaultMessage: 'Send a message to a Slack channel or user.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle', - { - defaultMessage: 'Send to Slack', - } - ), - validateConnector: (action: SlackActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - webhookUrl: new Array(), - }; - validationResult.errors = errors; - if (!action.secrets.webhookUrl) { - errors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: SlackActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - message: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: SlackActionFields, - actionParamsFields: SlackParamsFields, - }; -} - -const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors, docLinks }) => { - const { webhookUrl } = action.secrets; - - return ( - - - - - } - error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', - { - defaultMessage: 'Webhook URL', - } - )} - > - 0 && webhookUrl !== undefined} - name="webhookUrl" - placeholder="Example: https://hooks.slack.com/services" - value={webhookUrl || ''} - data-test-subj="slackWebhookUrlInput" - onChange={e => { - editActionSecrets('webhookUrl', e.target.value); - }} - onBlur={() => { - if (!webhookUrl) { - editActionSecrets('webhookUrl', ''); - } - }} - /> - - - ); -}; - -const SlackParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, - messageVariables, - defaultMessage, -}) => { - const { message } = actionParams; - useEffect(() => { - if (!message && defaultMessage && defaultMessage.length > 0) { - editAction('message', defaultMessage, index); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction(paramsProperty, (message ?? '').concat(` {{${variable}}}`), index); - }; - - return ( - - 0 && message !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel', - { - defaultMessage: 'Message', - } - )} - labelAppend={ - - onSelectMessageVariable('message', variable) - } - paramsProperty="message" - /> - } - > - 0 && message !== undefined} - name="message" - value={message || ''} - data-test-subj="slackMessageTextArea" - onChange={e => { - editAction('message', e.target.value, index); - }} - onBlur={() => { - if (!message) { - editAction('message', '', index); - } - }} - /> - - - ); -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/index.ts new file mode 100644 index 0000000000000..64ab6670754c9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/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 { getActionType as getSlackActionType } from './slack'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx new file mode 100644 index 0000000000000..78f4161cac827 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { SlackActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.slack'; +let actionTypeModel: ActionTypeModel; + +beforeAll(async () => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('slack connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is required.'], + }, + }); + }); +}); + +describe('slack action params validation', () => { + test('if action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx new file mode 100644 index 0000000000000..5d39cdb5ac387 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { SlackActionParams, SlackActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.slack', + iconClass: 'logoSlack', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle', + { + defaultMessage: 'Send to Slack', + } + ), + validateConnector: (action: SlackActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + webhookUrl: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.webhookUrl) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: SlackActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./slack_connectors')), + actionParamsFields: lazy(() => import('./slack_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx new file mode 100644 index 0000000000000..7d7f6fc086928 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from '@testing-library/react'; +import { SlackActionConnector } from '../types'; +import SlackActionFields from './slack_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('SlackActionFields renders', () => { + test('all connector fields is rendered', async () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackWebhookUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx new file mode 100644 index 0000000000000..ad3e76ad8ae6c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { SlackActionConnector } from '../types'; + +const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors, docLinks }) => { + const { webhookUrl } = action.secrets; + + return ( + + + + + } + error={errors.webhookUrl} + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', + { + defaultMessage: 'Webhook URL', + } + )} + > + 0 && webhookUrl !== undefined} + name="webhookUrl" + placeholder="Example: https://hooks.slack.com/services" + value={webhookUrl || ''} + data-test-subj="slackWebhookUrlInput" + onChange={e => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackActionFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx new file mode 100644 index 0000000000000..4183aeb48dec7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import SlackParamsFields from './slack_params'; + +describe('SlackParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="slackMessageTextArea"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackMessageTextArea"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx new file mode 100644 index 0000000000000..42fefdd41ef67 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useEffect } from 'react'; +import { EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { SlackActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +const SlackParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, +}) => { + const { message } = actionParams; + useEffect(() => { + if (!message && defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction(paramsProperty, (message ?? '').concat(` {{${variable}}}`), index); + }; + + return ( + + 0 && message !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel', + { + defaultMessage: 'Message', + } + )} + labelAppend={ + + onSelectMessageVariable('message', variable) + } + paramsProperty="message" + /> + } + > + 0 && message !== undefined} + name="message" + value={message || ''} + data-test-subj="slackMessageTextArea" + onChange={e => { + editAction('message', e.target.value, index); + }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx deleted file mode 100644 index 7d0082708075f..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx +++ /dev/null @@ -1,179 +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, { FunctionComponent } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { WebhookActionParams, WebhookActionConnector } from './types'; - -const ACTION_TYPE_ID = '.webhook'; -let actionTypeModel: ActionTypeModel; - -beforeAll(() => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logoWebhook'); - }); -}); - -describe('webhook connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - name: 'webhook', - isPreconfigured: false, - config: { - method: 'PUT', - url: 'http:\\test', - headers: { 'content-type': 'text' }, - }, - } as WebhookActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - url: [], - method: [], - user: [], - password: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: { - user: 'user', - }, - id: 'test', - actionTypeId: '.webhook', - name: 'webhook', - config: { - method: 'PUT', - }, - } as WebhookActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - url: ['URL is required.'], - method: [], - user: [], - password: ['Password is required.'], - }, - }); - }); -}); - -describe('webhook action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - body: 'message {test}', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { body: [] }, - }); - }); -}); - -describe('WebhookActionConnectorFields renders', () => { - test('all connector fields is rendered', () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - isPreconfigured: false, - name: 'webhook', - config: { - method: 'PUT', - url: 'http:\\test', - headers: { 'content-type': 'text' }, - }, - } as WebhookActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> - ); - expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); - wrapper - .find('[data-test-subj="webhookViewHeadersSwitch"]') - .first() - .simulate('click'); - expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('WebhookParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - body: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="webhookBodyEditor"]') - .first() - .prop('value') - ).toStrictEqual('test message'); - expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').length > 0).toBeTruthy(); - }); - - test('params validation fails when body is not valid', () => { - const actionParams = { - body: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - body: ['Body is required.'], - }, - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/index.ts new file mode 100644 index 0000000000000..c43cab26b072e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/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 { getActionType as getWebhookActionType } from './webhook'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx new file mode 100644 index 0000000000000..3413465d70d93 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { WebhookActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.webhook'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoWebhook'); + }); +}); + +describe('webhook connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: false, + config: { + method: 'PUT', + url: 'http:\\test', + headers: { 'content-type': 'text' }, + }, + } as WebhookActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: [], + method: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + }, + } as WebhookActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: ['URL is required.'], + method: [], + user: [], + password: ['Password is required.'], + }, + }); + }); +}); + +describe('webhook action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + body: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: [] }, + }); + }); + + test('params validation fails when body is not valid', () => { + const actionParams = { + body: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: ['Body is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx new file mode 100644 index 0000000000000..9f33e4491233a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -0,0 +1,99 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { WebhookActionParams, WebhookActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.webhook', + iconClass: 'logoWebhook', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', + { + defaultMessage: 'Send a request to a web service.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', + { + defaultMessage: 'Webhook data', + } + ), + validateConnector: (action: WebhookActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + url: new Array(), + method: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.url) { + errors.url.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } + ) + ); + } + if (!action.config.method) { + errors.method.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } + ) + ); + } + if (!action.secrets.user && action.secrets.password) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password && action.secrets.user) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: WebhookActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + body: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.body?.length) { + errors.body.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./webhook_connectors')), + actionParamsFields: lazy(() => import('./webhook_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx new file mode 100644 index 0000000000000..842ec51785355 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { WebhookActionConnector } from '../types'; +import WebhookActionConnectorFields from './webhook_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('WebhookActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + isPreconfigured: false, + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: { 'content-type': 'text' }, + }, + } as WebhookActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + /> + ); + expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); + wrapper + .find('[data-test-subj="webhookViewHeadersSwitch"]') + .first() + .simulate('click'); + expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx similarity index 71% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index daa5a6caeabe9..e163463602d9f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -19,112 +19,15 @@ import { EuiDescriptionListDescription, EuiDescriptionListTitle, EuiTitle, - EuiCodeEditor, EuiSwitch, EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { WebhookActionParams, WebhookActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { WebhookActionConnector } from '../types'; const HTTP_VERBS = ['post', 'put']; -export function getActionType(): ActionTypeModel { - return { - id: '.webhook', - iconClass: 'logoWebhook', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', - { - defaultMessage: 'Send a request to a web service.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', - { - defaultMessage: 'Webhook data', - } - ), - validateConnector: (action: WebhookActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - url: new Array(), - method: new Array(), - user: new Array(), - password: new Array(), - }; - validationResult.errors = errors; - if (!action.config.url) { - errors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); - } - if (!action.config.method) { - errors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); - } - if (!action.secrets.user && action.secrets.password) { - errors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', - { - defaultMessage: 'Username is required.', - } - ) - ); - } - if (!action.secrets.password && action.secrets.user) { - errors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: WebhookActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - body: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: WebhookActionConnectorFields, - actionParamsFields: WebhookParamsFields, - }; -} - const WebhookActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { @@ -457,56 +360,5 @@ const WebhookActionConnectorFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - messageVariables, - errors, -}) => { - const { body } = actionParams; - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction(paramsProperty, (body ?? '').concat(` {{${variable}}}`), index); - }; - return ( - - 0 && body !== undefined} - fullWidth - error={errors.body} - labelAppend={ - onSelectMessageVariable('body', variable)} - paramsProperty="body" - /> - } - > - { - editAction('body', json, index); - }} - /> - - - ); -}; +// eslint-disable-next-line import/no-default-export +export { WebhookActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx new file mode 100644 index 0000000000000..5ca27a53083f9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import WebhookParamsFields from './webhook_params'; + +describe('WebhookParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + body: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="webhookBodyEditor"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx new file mode 100644 index 0000000000000..9e802b96e16be --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx @@ -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 React, { Fragment } from 'react'; +import { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { WebhookActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +const WebhookParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + errors, +}) => { + const { body } = actionParams; + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction(paramsProperty, (body ?? '').concat(` {{${variable}}}`), index); + }; + return ( + + 0 && body !== undefined} + fullWidth + error={errors.body} + labelAppend={ + onSelectMessageVariable('body', variable)} + paramsProperty="body" + /> + } + > + { + editAction('body', json, index); + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { WebhookParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 4d0a9980f2231..b5f3b63c58a93 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -167,3 +167,6 @@ export const TriggersActionsUIHome: React.FunctionComponent ); }; + +// eslint-disable-next-line import/no-default-export +export { TriggersActionsUIHome as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 6bb8a8f4e4c10..06ddce39567a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -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 React, { Fragment } from 'react'; +import React, { Fragment, Suspense } from 'react'; import { EuiForm, EuiCallOut, @@ -12,6 +12,9 @@ import { EuiSpacer, EuiFieldText, EuiFormRow, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -151,14 +154,24 @@ export const ActionConnectorForm = ({ {FieldsComponent !== null ? ( - + + + + + + } + > + + ) : null} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 6935dda358d9c..ae179f56f0c83 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { Fragment, Suspense, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -27,6 +27,7 @@ import { EuiCallOut, EuiHorizontalRule, EuiText, + EuiLoadingSpinner, } from '@elastic/eui'; import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; @@ -282,14 +283,24 @@ export const ActionForm = ({ {ParamsFieldsComponent ? ( - + + + + + + } + > + + ) : null} ) : ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 9198607df7863..0caa880c4df00 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -118,6 +118,6 @@ export async function getAlertData( } } -export const AlertDetailsRouteWithApi = withActionOperations( - withBulkAlertOperations(AlertDetailsRoute) -); +const AlertDetailsRouteWithApi = withActionOperations(withBulkAlertOperations(AlertDetailsRoute)); +// eslint-disable-next-line import/no-default-export +export { AlertDetailsRouteWithApi as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 93e61cf5b4f43..62173a6196b98 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -23,7 +23,11 @@ const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { }; }; -const getTestActionType = (id?: string, iconClass?: string, selectedMessage?: string) => { +const getTestActionType = ( + id?: string, + iconClass?: string, + selectedMessage?: string +): ActionTypeModel => { return { id: id || 'my-action-type', iconClass: iconClass || 'test', diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6f33bcb8b226d..cc511434267cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { HttpSetup, DocLinksStart } from 'kibana/public'; +import { ComponentType } from 'react'; import { ActionGroup } from '../../alerting/common'; import { ActionType } from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; @@ -19,14 +20,16 @@ export { ActionType }; export type ActionTypeIndex = Record; export type AlertTypeIndex = Record; -export type ActionTypeRegistryContract = PublicMethodsOf>; +export type ActionTypeRegistryContract = PublicMethodsOf< + TypeRegistry> +>; export type AlertTypeRegistryContract = PublicMethodsOf>; export interface ActionConnectorFieldsProps { action: TActionConnector; editActionConfig: (property: string, value: any) => void; editActionSecrets: (property: string, value: any) => void; - errors: { [key: string]: string[] }; + errors: IErrorObject; docLinks: DocLinksStart; http?: HttpSetup; } @@ -35,7 +38,7 @@ export interface ActionParamsProps { actionParams: TParams; index: number; editAction: (property: string, value: any, index: number) => void; - errors: { [key: string]: string[] }; + errors: IErrorObject; messageVariables?: string[]; defaultMessage?: string; } @@ -45,15 +48,19 @@ export interface Pagination { size: number; } -export interface ActionTypeModel { +export interface ActionTypeModel { id: string; iconClass: string; selectMessage: string; actionTypeTitle?: string; validateConnector: (connector: any) => ValidationResult; validateParams: (actionParams: any) => ValidationResult; - actionConnectorFields: React.FunctionComponent | null; - actionParamsFields: any; + actionConnectorFields: React.LazyExoticComponent< + ComponentType> + > | null; + actionParamsFields: React.LazyExoticComponent< + ComponentType> + > | null; } export interface ValidationResult { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index e787a3594dfe6..602b9929485e0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -13,7 +13,7 @@ import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex } from './utils // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('add_prepackaged_rules', () => { describe('validation errors', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index 46645a9b5a944..43630d81e64ea 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -25,7 +25,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('create_rules', () => { describe('validation errors', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 117300be417d5..7d406777e23f0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -23,7 +23,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('create_rules_bulk', () => { describe('validation errors', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts index fb701681419d8..4902060f2c6ee 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts @@ -23,7 +23,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('delete_rules', () => { describe('deleting rules', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts index ac58ba4c77e4e..8ddb5f0656019 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts @@ -23,7 +23,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('delete_rules_bulk', () => { describe('deleting rules bulk using DELETE', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts index 51bdb9e45dc0c..ed1f92457e782 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts @@ -21,7 +21,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('export_rules', () => { describe('exporting rules', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts index feb4ecd125f7e..c0356f877377a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts @@ -22,7 +22,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('find_rules', () => { beforeEach(async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts index 44847d5c8146c..b4c9632320271 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts @@ -19,7 +19,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('find_statuses', () => { beforeEach(async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts index e2dce77c1d70a..a366c04330e9b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts @@ -16,7 +16,7 @@ import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, getSimpleRule // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('get_prepackaged_rules_status', () => { describe('getting prepackaged rules status', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 4def508fabbc3..ac0f51abe1c10 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -22,10 +22,59 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('import_rules', () => { - describe('importing rules', () => { + describe('importing rules without an index', () => { + it('should not create a rule if the index does not exist', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .expect(400); + + // We have to wait up to 5 seconds for any unresolved promises to flush + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Try to fetch the rule which should still be a 404 (not found) + const { body } = await supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`).send(); + + expect(body).to.eql({ + status_code: 404, + message: 'rule_id: "rule-1" not found', + }); + }); + + it('should return an error that the index needs to be created before you are able to import a single rule', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .expect(400); + + expect(body).to.eql({ + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }); + }); + + it('should return an error that the index needs to be created before you are able to import two rules', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .expect(400); + + expect(body).to.eql({ + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }); + }); + }); + + describe('importing rules with an index', () => { beforeEach(async () => { await createSignalsIndex(supertest); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts index c3ecf79e58955..295bd456eeebf 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts @@ -22,7 +22,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('patch_rules', () => { describe('patch rules', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts index 8515d1cf404ea..14c9ca76f6aac 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts @@ -22,7 +22,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('patch_rules_bulk', () => { describe('patch rules bulk', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts index 4d7449dae2dbd..1ae6871348bbb 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts @@ -23,7 +23,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('read_rules', () => { describe('reading rules', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts index 4b81b7d4304b2..42501c005d994 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts @@ -22,7 +22,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('update_rules', () => { describe('update rules', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts index 760e17ae1752e..b7f998d4043f7 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts @@ -22,7 +22,7 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('update_rules_bulk', () => { describe('update rules bulk', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts index e508cf1aaa2e0..5eabecf96f3e6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Client } from '@elastic/elasticsearch'; +import { SuperTest } from 'supertest'; +import supertestAsPromised from 'supertest-as-promised'; import { OutputRuleAlertRest } from '../../../../plugins/siem/server/lib/detection_engine/types'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../plugins/siem/common/constants'; @@ -187,12 +190,13 @@ export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial => { +export const deleteAllAlerts = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', q: 'type:alert', - waitForCompletion: true, - refresh: 'wait_for', + wait_for_completion: true, + refresh: true, + body: {}, }); }; @@ -200,12 +204,13 @@ export const deleteAllAlerts = async (es: any): Promise => { * Remove all rules statuses from the .kibana index * @param es The ElasticSearch handle */ -export const deleteAllRulesStatuses = async (es: any): Promise => { +export const deleteAllRulesStatuses = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', q: 'type:siem-detection-engine-rule-status', - waitForCompletion: true, - refresh: 'wait_for', + wait_for_completion: true, + refresh: true, + body: {}, }); }; @@ -213,7 +218,9 @@ export const deleteAllRulesStatuses = async (es: any): Promise => { * Creates the signals index for use inside of beforeEach blocks of tests * @param supertest The supertest client library */ -export const createSignalsIndex = async (supertest: any): Promise => { +export const createSignalsIndex = async ( + supertest: SuperTest +): Promise => { await supertest .post(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') @@ -225,7 +232,9 @@ export const createSignalsIndex = async (supertest: any): Promise => { * Deletes the signals index for use inside of afterEach blocks of tests * @param supertest The supertest client library */ -export const deleteSignalsIndex = async (supertest: any): Promise => { +export const deleteSignalsIndex = async ( + supertest: SuperTest +): Promise => { await supertest .delete(DETECTION_ENGINE_INDEX_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index fcf7298c5577a..d8214dc5ffefd 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -19,6 +19,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { log.debug('load graph/secrepo data'); await esArchiver.loadIfNeeded('graph/secrepo'); await esArchiver.load('empty_kibana'); + await PageObjects.common.navigateToApp('settings'); log.debug('create secrepo index pattern'); await PageObjects.settings.createIndexPattern('secrepo', '@timestamp'); log.debug('navigateTo graph'); diff --git a/x-pack/test/functional/apps/machine_learning/anomaly_detection/single_metric_job.ts b/x-pack/test/functional/apps/machine_learning/anomaly_detection/single_metric_job.ts index 30327e8a422c1..0f8655e3c6bbc 100644 --- a/x-pack/test/functional/apps/machine_learning/anomaly_detection/single_metric_job.ts +++ b/x-pack/test/functional/apps/machine_learning/anomaly_detection/single_metric_job.ts @@ -71,8 +71,7 @@ export default function({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; - // Breaking latest ES snapshots: https://github.com/elastic/kibana/issues/65377 - describe.skip('single metric', function() { + describe('single metric', function() { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts index d87d7d654f5c4..93f225989592e 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts @@ -13,8 +13,7 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // TODO add fix for https://github.com/elastic/elasticsearch/pull/56118 - describe.skip('jobs cloning supported by UI form', function() { + describe('jobs cloning supported by UI form', function() { const testDataList: Array<{ suiteTitle: string; archive: string; diff --git a/x-pack/test/functional/apps/security/doc_level_security_roles.js b/x-pack/test/functional/apps/security/doc_level_security_roles.js index 480fa6599e036..47a882a33eed8 100644 --- a/x-pack/test/functional/apps/security/doc_level_security_roles.js +++ b/x-pack/test/functional/apps/security/doc_level_security_roles.js @@ -21,6 +21,7 @@ export default function({ getService, getPageObjects }) { await esArchiver.loadIfNeeded('security/dlstest'); await browser.setWindowSize(1600, 1000); + await PageObjects.common.navigateToApp('settings'); await PageObjects.settings.createIndexPattern('dlstest', null); await PageObjects.settings.navigateTo();