diff --git a/docs/apm/api.asciidoc b/docs/apm/api.asciidoc
index 54159b642dd1a..2fbeea0534fc0 100644
--- a/docs/apm/api.asciidoc
+++ b/docs/apm/api.asciidoc
@@ -355,6 +355,7 @@ allowing you to easily see how these events are impacting the performance of you
By default, annotations are stored in a newly created `observability-annotations` index.
The name of this index can be changed in your `config.yml` by editing `xpack.observability.annotations.index`.
+If you change the default index name, you'll also need to <> accordingly.
The following APIs are available:
diff --git a/docs/apm/apm-app-users.asciidoc b/docs/apm/apm-app-users.asciidoc
index 442a07d279725..d766c866f87e4 100644
--- a/docs/apm/apm-app-users.asciidoc
+++ b/docs/apm/apm-app-users.asciidoc
@@ -4,7 +4,7 @@
:beat_default_index_prefix: apm
:beat_kib_app: APM app
-:annotation_index: `observability-annotations`
+:annotation_index: observability-annotations
++++
Users and privileges
@@ -102,6 +102,54 @@ Here are two examples:
*********************************** ***********************************
////
+[role="xpack"]
+[[apm-app-annotation-user-create]]
+=== APM app annotation user
+
+++++
+Create an annotation user
+++++
+
+NOTE: By default, the `apm_user` built-in role provides access to Observability annotations.
+You only need to create an annotation user if the default annotation index
+defined in <> has been customized.
+
+[[apm-app-annotation-user]]
+==== Annotation user
+
+View deployment annotations in the APM app.
+
+. Create a new role, named something like `annotation_user`,
+and assign the following privileges:
++
+[options="header"]
+|====
+|Type | Privilege | Purpose
+
+|Index
+|`read` on +\{ANNOTATION_INDEX\}+^1^
+|Read-only access to the observability annotation index
+
+|Index
+|`view_index_metadata` on +\{ANNOTATION_INDEX\}+^1^
+|Read-only access to observability annotation index metadata
+|====
++
+^1^ +\{ANNOTATION_INDEX\}+ should be the index name you've defined in
+<>.
+
+. Assign the `annotation_user` created previously, and the built-in roles necessary to create
+a <> or <> APM reader to any users that need to view annotations in the APM app
+
+[[apm-app-annotation-api]]
+==== Annotation API
+
+See <>.
+
+////
+*********************************** ***********************************
+////
+
[role="xpack"]
[[apm-app-central-config-user]]
=== APM app central config user
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.destroy.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.destroy.md
deleted file mode 100644
index 3a8e1b9dae5a6..0000000000000
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.destroy.md
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [destroy](./kibana-plugin-plugins-data-public.indexpattern.destroy.md)
-
-## IndexPattern.destroy() method
-
-Signature:
-
-```typescript
-destroy(): Promise<{}> | undefined;
-```
-Returns:
-
-`Promise<{}> | undefined`
-
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md
index bc999a3bb48e3..a37f115358922 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md
@@ -39,7 +39,6 @@ export declare class IndexPattern implements IIndexPattern
| [\_fetchFields()](./kibana-plugin-plugins-data-public.indexpattern._fetchfields.md) | | |
| [addScriptedField(name, script, fieldType, lang)](./kibana-plugin-plugins-data-public.indexpattern.addscriptedfield.md) | | |
| [create(allowOverride)](./kibana-plugin-plugins-data-public.indexpattern.create.md) | | |
-| [destroy()](./kibana-plugin-plugins-data-public.indexpattern.destroy.md) | | |
| [getAggregationRestrictions()](./kibana-plugin-plugins-data-public.indexpattern.getaggregationrestrictions.md) | | |
| [getComputedFields()](./kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md) | | |
| [getFieldByName(name)](./kibana-plugin-plugins-data-public.indexpattern.getfieldbyname.md) | | |
diff --git a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc
index b1cf2d650e576..e3f1703f08e88 100644
--- a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc
+++ b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc
@@ -28,12 +28,12 @@ two out-of-the box connectors: <> and <
actionTypeId: .slack <2>
name: 'Slack #xyz' <3>
- secrets: <4>
+ secrets:
webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz'
webhook-service:
actionTypeId: .webhook
name: 'Email service'
- config:
+ config: <4>
url: 'https://email-alert-service.elastic.co'
method: post
headers:
diff --git a/package.json b/package.json
index 8e51f9207eaf1..2f6b643b02601 100644
--- a/package.json
+++ b/package.json
@@ -455,9 +455,10 @@
"is-path-inside": "^2.1.0",
"istanbul-instrumenter-loader": "3.0.1",
"jest": "^25.5.4",
- "jest-environment-jsdom-thirteen": "^1.0.1",
+ "jest-canvas-mock": "^2.2.0",
"jest-circus": "^25.5.4",
"jest-cli": "^25.5.4",
+ "jest-environment-jsdom-thirteen": "^1.0.1",
"jest-raw-loader": "^1.0.1",
"jimp": "^0.9.6",
"json5": "^1.0.1",
diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
index 1466865df8d98..211cfac3806ad 100644
--- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
+++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap
@@ -1,5 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`builds expected bundles, saves bundle counts to metadata: OptimizerConfig 1`] = `
+OptimizerConfig {
+ "bundles": Array [
+ Bundle {
+ "cache": BundleCache {
+ "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public/.kbn-optimizer-cache,
+ "state": undefined,
+ },
+ "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar,
+ "id": "bar",
+ "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public,
+ "publicDirNames": Array [
+ "public",
+ ],
+ "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo,
+ "type": "plugin",
+ },
+ Bundle {
+ "cache": BundleCache {
+ "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public/.kbn-optimizer-cache,
+ "state": undefined,
+ },
+ "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo,
+ "id": "foo",
+ "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public,
+ "publicDirNames": Array [
+ "public",
+ ],
+ "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo,
+ "type": "plugin",
+ },
+ ],
+ "cache": true,
+ "dist": false,
+ "inspectWorkers": false,
+ "maxWorkerCount": 1,
+ "plugins": Array [
+ Object {
+ "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar,
+ "extraPublicDirs": Array [],
+ "id": "bar",
+ "isUiPlugin": true,
+ },
+ Object {
+ "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo,
+ "extraPublicDirs": Array [],
+ "id": "foo",
+ "isUiPlugin": true,
+ },
+ Object {
+ "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz,
+ "extraPublicDirs": Array [],
+ "id": "baz",
+ "isUiPlugin": false,
+ },
+ ],
+ "profileWebpack": false,
+ "repoRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo,
+ "themeTags": Array [
+ "v7dark",
+ "v7light",
+ ],
+ "watch": false,
+}
+`;
+
exports[`prepares assets for distribution: bar bundle 1`] = `"(function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"\\";return __webpack_require__(__webpack_require__.s=5)})([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i {
await del(TMP_DIR);
});
-// FLAKY: https://github.com/elastic/kibana/issues/70762
-it.skip('builds expected bundles, saves bundle counts to metadata', async () => {
+it('builds expected bundles, saves bundle counts to metadata', async () => {
const config = OptimizerConfig.create({
repoRoot: MOCK_REPO_DIR,
pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')],
@@ -75,7 +74,11 @@ it.skip('builds expected bundles, saves bundle counts to metadata', async () =>
expect(config).toMatchSnapshot('OptimizerConfig');
const msgs = await runOptimizer(config)
- .pipe(logOptimizerState(log, config), toArray())
+ .pipe(
+ logOptimizerState(log, config),
+ filter((x) => x.event?.type !== 'worker stdio'),
+ toArray()
+ )
.toPromise();
const assert = (statement: string, truth: boolean, altStates?: OptimizerUpdate[]) => {
@@ -168,8 +171,7 @@ it.skip('builds expected bundles, saves bundle counts to metadata', async () =>
`);
});
-// FLAKY: https://github.com/elastic/kibana/issues/70764
-it.skip('uses cache on second run and exist cleanly', async () => {
+it('uses cache on second run and exist cleanly', async () => {
const config = OptimizerConfig.create({
repoRoot: MOCK_REPO_DIR,
pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')],
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterparamchange.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterparamchange.png
deleted file mode 100644
index bc41213edc7b6..0000000000000
Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterparamchange.png and /dev/null differ
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterresize.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterresize.png
deleted file mode 100644
index 3788a57ae2421..0000000000000
Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/afterresize.png and /dev/null differ
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/basicdraw.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/basicdraw.png
deleted file mode 100644
index 3716867865e44..0000000000000
Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/basicdraw.png and /dev/null differ
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/simpleload.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/simpleload.png
deleted file mode 100644
index 6ea090562d46e..0000000000000
Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/simpleload.png and /dev/null differ
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud_visualization.js b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud_visualization.js
deleted file mode 100644
index 4a6e9e7765213..0000000000000
--- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud_visualization.js
+++ /dev/null
@@ -1,202 +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 expect from '@kbn/expect';
-import ngMock from 'ng_mock';
-import { ImageComparator } from 'test_utils/image_comparator';
-import basicdrawPng from './basicdraw.png';
-import afterresizePng from './afterresize.png';
-import afterparamChange from './afterparamchange.png';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { ExprVis } from '../../../../../../plugins/visualizations/public/expressions/vis';
-
-// Replace with mock when converting to jest tests
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { seedColors } from '../../../../../../plugins/charts/public/services/colors/seed_colors';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { BaseVisType } from '../../../../../../plugins/visualizations/public/vis_types/base_vis_type';
-// Will be replaced with new path when tests are moved
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { createTagCloudVisTypeDefinition } from '../../../../../../plugins/vis_type_tagcloud/public/tag_cloud_type';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { createTagCloudVisualization } from '../../../../../../plugins/vis_type_tagcloud/public/components/tag_cloud_visualization';
-import { npStart } from 'ui/new_platform';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { setFormatService } from '../../../../../../plugins/vis_type_tagcloud/public/services';
-
-const THRESHOLD = 0.65;
-const PIXEL_DIFF = 64;
-describe('TagCloudVisualizationTest', function () {
- let domNode;
- let vis;
- let imageComparator;
-
- const dummyTableGroup = {
- columns: [
- {
- id: 'col-0',
- title: 'geo.dest: Descending',
- },
- {
- id: 'col-1',
- title: 'Count',
- },
- ],
- rows: [
- { 'col-0': 'CN', 'col-1': 26 },
- { 'col-0': 'IN', 'col-1': 17 },
- { 'col-0': 'US', 'col-1': 6 },
- { 'col-0': 'DE', 'col-1': 4 },
- { 'col-0': 'BR', 'col-1': 3 },
- ],
- };
- const TagCloudVisualization = createTagCloudVisualization({
- colors: {
- seedColors,
- },
- });
-
- before(() => setFormatService(npStart.plugins.data.fieldFormats));
-
- beforeEach(ngMock.module('kibana'));
-
- describe('TagCloudVisualization - basics', function () {
- beforeEach(async function () {
- const visType = new BaseVisType(createTagCloudVisTypeDefinition({ colors: seedColors }));
- setupDOM('512px', '512px');
- imageComparator = new ImageComparator();
- vis = new ExprVis({
- type: visType,
- params: {
- bucket: { accessor: 0, format: {} },
- metric: { accessor: 0, format: {} },
- },
- data: {},
- });
- });
-
- afterEach(function () {
- teardownDOM();
- imageComparator.destroy();
- });
-
- it('simple draw', async function () {
- const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
-
- await tagcloudVisualization.render(dummyTableGroup, vis.params, {
- resize: false,
- params: true,
- aggs: true,
- data: true,
- uiState: false,
- });
-
- const svgNode = domNode.querySelector('svg');
- const mismatchedPixels = await imageComparator.compareDOMContents(
- svgNode.outerHTML,
- 512,
- 512,
- basicdrawPng,
- THRESHOLD
- );
- expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
- });
-
- it('with resize', async function () {
- const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
- await tagcloudVisualization.render(dummyTableGroup, vis.params, {
- resize: false,
- params: true,
- aggs: true,
- data: true,
- uiState: false,
- });
-
- domNode.style.width = '256px';
- domNode.style.height = '368px';
- await tagcloudVisualization.render(dummyTableGroup, vis.params, {
- resize: true,
- params: false,
- aggs: false,
- data: false,
- uiState: false,
- });
-
- const svgNode = domNode.querySelector('svg');
- const mismatchedPixels = await imageComparator.compareDOMContents(
- svgNode.outerHTML,
- 256,
- 368,
- afterresizePng,
- THRESHOLD
- );
- expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
- });
-
- it('with param change', async function () {
- const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
- await tagcloudVisualization.render(dummyTableGroup, vis.params, {
- resize: false,
- params: true,
- aggs: true,
- data: true,
- uiState: false,
- });
-
- domNode.style.width = '256px';
- domNode.style.height = '368px';
- vis.params.orientation = 'right angled';
- vis.params.minFontSize = 70;
- await tagcloudVisualization.render(dummyTableGroup, vis.params, {
- resize: true,
- params: true,
- aggs: false,
- data: false,
- uiState: false,
- });
-
- const svgNode = domNode.querySelector('svg');
- const mismatchedPixels = await imageComparator.compareDOMContents(
- svgNode.outerHTML,
- 256,
- 368,
- afterparamChange,
- THRESHOLD
- );
- expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
- });
- });
-
- function setupDOM(width, height) {
- domNode = document.createElement('div');
- domNode.style.top = '0';
- domNode.style.left = '0';
- domNode.style.width = width;
- domNode.style.height = height;
- domNode.style.position = 'fixed';
- domNode.style.border = '1px solid blue';
- domNode.style['pointer-events'] = 'none';
- document.body.appendChild(domNode);
- }
-
- function teardownDOM() {
- domNode.innerHTML = '';
- document.body.removeChild(domNode);
- }
-});
diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts
index dab11ad0ce29a..2acb9d5f767ad 100644
--- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts
+++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts
@@ -224,7 +224,7 @@ export class IndexPattern implements IIndexPattern {
this.sourceFilters = spec.sourceFilters;
// ignoring this because the same thing happens elsewhere but via _.assign
- // @ts-ignore
+ // @ts-expect-error
this.fields = spec.fields || [];
this.typeMeta = spec.typeMeta;
this.fieldFormatMap = _.mapValues(fieldFormatMap, (mapping) => {
@@ -473,21 +473,8 @@ export class IndexPattern implements IIndexPattern {
async create(allowOverride: boolean = false) {
const _create = async (duplicateId?: string) => {
if (duplicateId) {
- const duplicatePattern = new IndexPattern(duplicateId, {
- getConfig: this.getConfig,
- savedObjectsClient: this.savedObjectsClient,
- apiClient: this.apiClient,
- patternCache: this.patternCache,
- fieldFormats: this.fieldFormats,
- onNotification: this.onNotification,
- onError: this.onError,
- uiSettingsValues: {
- shortDotsEnable: this.shortDotsEnable,
- metaFields: this.metaFields,
- },
- });
-
- await duplicatePattern.destroy();
+ this.patternCache.clear(duplicateId);
+ await this.savedObjectsClient.delete(savedObjectType, duplicateId);
}
const body = this.prepBody();
@@ -634,11 +621,4 @@ export class IndexPattern implements IIndexPattern {
toString() {
return '' + this.toJSON();
}
-
- destroy() {
- if (this.id) {
- this.patternCache.clear(this.id);
- return this.savedObjectsClient.delete(savedObjectType, this.id);
- }
- }
}
diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts
index 2eb9744fc16b3..a1842d31479c0 100644
--- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts
+++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.test.ts
@@ -53,6 +53,7 @@ describe('IndexPatterns', () => {
Array>
>
);
+ savedObjectsClient.delete = jest.fn(() => Promise.resolve({}) as Promise);
indexPatterns = new IndexPatternsService({
uiSettings: ({
@@ -98,4 +99,13 @@ describe('IndexPatterns', () => {
await indexPatterns.getFields(['id', 'title'], true);
expect(savedObjectsClient.find).toHaveBeenCalledTimes(3);
});
+
+ test('deletes the index pattern', async () => {
+ const id = '1';
+ const indexPattern = await indexPatterns.get(id);
+
+ expect(indexPattern).toBeDefined();
+ await indexPatterns.delete(id);
+ expect(indexPattern).not.toBe(await indexPatterns.get(id));
+ });
});
diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts
index ef03ca8fe2d14..a07ffaf92aea5 100644
--- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts
+++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts
@@ -228,6 +228,15 @@ export class IndexPatternsService {
return indexPattern.init();
}
+
+ /**
+ * Deletes an index pattern from .kibana index
+ * @param indexPatternId: Id of kibana Index Pattern to delete
+ */
+ async delete(indexPatternId: string) {
+ indexPatternCache.clear(indexPatternId);
+ return this.savedObjectsClient.delete('index-pattern', indexPatternId);
+ }
}
export type IndexPatternsContract = PublicMethodsOf;
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index 670b40e7d9472..2b18584bcd781 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -988,8 +988,6 @@ export class IndexPattern implements IIndexPattern {
// (undocumented)
create(allowOverride?: boolean): Promise;
// (undocumented)
- destroy(): Promise<{}> | undefined;
- // (undocumented)
_fetchFields(): Promise;
// (undocumented)
fieldFormatMap: any;
diff --git a/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx
index 302477a5fff5e..561c33519f96f 100644
--- a/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx
+++ b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx
@@ -56,7 +56,7 @@ export function NoDataPopover({
{i18n.translate('data.noDataPopover.content', {
defaultMessage:
- "This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts",
+ "This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts.",
})}
@@ -66,11 +66,13 @@ export function NoDataPopover({
step={1}
stepsTotal={1}
isStepOpen={noDataPopoverVisible}
- subtitle={i18n.translate('data.noDataPopover.title', { defaultMessage: 'Tip' })}
- title=""
+ subtitle={i18n.translate('data.noDataPopover.subtitle', { defaultMessage: 'Tip' })}
+ title={i18n.translate('data.noDataPopover.title', { defaultMessage: 'Empty dataset' })}
footerAction={
{
storage.set(NO_DATA_POPOVER_STORAGE_KEY, true);
diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts
index 7bfb14b8bfa1c..8df9f08e9c40b 100644
--- a/src/plugins/expressions/common/execution/execution.ts
+++ b/src/plugins/expressions/common/execution/execution.ts
@@ -18,7 +18,7 @@
*/
import { keys, last, mapValues, reduce, zipObject } from 'lodash';
-import { Executor } from '../executor';
+import { Executor, ExpressionExecOptions } from '../executor';
import { createExecutionContainer, ExecutionContainer } from './container';
import { createError } from '../util';
import { Defer, now } from '../../../kibana_utils/common';
@@ -31,6 +31,7 @@ import {
parse,
formatExpression,
parseExpression,
+ ExpressionAstNode,
} from '../ast';
import { ExecutionContext, DefaultInspectorAdapters } from './types';
import { getType, ExpressionValue } from '../expression_types';
@@ -382,7 +383,7 @@ export class Execution<
const resolveArgFns = mapValues(argAstsWithDefaults, (asts, argName) => {
return asts.map((item: ExpressionAstExpression) => {
return async (subInput = input) => {
- const output = await this.params.executor.interpret(item, subInput, {
+ const output = await this.interpret(item, subInput, {
debug: this.params.debug,
});
if (isExpressionValueError(output)) throw output.error;
@@ -415,4 +416,28 @@ export class Execution<
// function which would be treated as a promise
return { resolvedArgs };
}
+
+ public async interpret(
+ ast: ExpressionAstNode,
+ input: T,
+ options?: ExpressionExecOptions
+ ): Promise {
+ switch (getType(ast)) {
+ case 'expression':
+ const execution = this.params.executor.createExecution(
+ ast as ExpressionAstExpression,
+ this.context,
+ options
+ );
+ execution.start(input);
+ return await execution.result;
+ case 'string':
+ case 'number':
+ case 'null':
+ case 'boolean':
+ return ast;
+ default:
+ throw new Error(`Unknown AST object: ${JSON.stringify(ast)}`);
+ }
+ }
}
diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts
index 2ecbc5f75a9e8..2b5f9f2556d89 100644
--- a/src/plugins/expressions/common/executor/executor.ts
+++ b/src/plugins/expressions/common/executor/executor.ts
@@ -26,8 +26,7 @@ import { Execution, ExecutionParams } from '../execution/execution';
import { IRegistry } from '../types';
import { ExpressionType } from '../expression_types/expression_type';
import { AnyExpressionTypeDefinition } from '../expression_types/types';
-import { getType } from '../expression_types';
-import { ExpressionAstExpression, ExpressionAstNode } from '../ast';
+import { ExpressionAstExpression } from '../ast';
import { typeSpecs } from '../expression_types/specs';
import { functionSpecs } from '../expression_functions/specs';
@@ -154,34 +153,6 @@ export class Executor = Record(
- ast: ExpressionAstNode,
- input: T,
- options?: ExpressionExecOptions
- ): Promise {
- switch (getType(ast)) {
- case 'expression':
- return await this.interpretExpression(ast as ExpressionAstExpression, input, options);
- case 'string':
- case 'number':
- case 'null':
- case 'boolean':
- return ast;
- default:
- throw new Error(`Unknown AST object: ${JSON.stringify(ast)}`);
- }
- }
-
- public async interpretExpression(
- ast: string | ExpressionAstExpression,
- input: T,
- options?: ExpressionExecOptions
- ): Promise {
- const execution = this.createExecution(ast, undefined, options);
- execution.start(input);
- return await execution.result;
- }
-
/**
* Execute expression and return result.
*
diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx
index eab8b2c231c9c..090c72d319f8c 100644
--- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx
+++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx
@@ -83,9 +83,14 @@ const confirmModalOptionsDelete = {
export const EditIndexPattern = withRouter(
({ indexPattern, history, location }: EditIndexPatternProps) => {
- const { uiSettings, indexPatternManagementStart, overlays, savedObjects, chrome } = useKibana<
- IndexPatternManagmentContext
- >().services;
+ const {
+ uiSettings,
+ indexPatternManagementStart,
+ overlays,
+ savedObjects,
+ chrome,
+ data,
+ } = useKibana().services;
const [fields, setFields] = useState(indexPattern.getNonScriptedFields());
const [conflictedFields, setConflictedFields] = useState(
indexPattern.fields.filter((field) => field.type === 'conflict')
@@ -138,10 +143,11 @@ export const EditIndexPattern = withRouter(
uiSettings.set('defaultIndex', otherPatterns[0].id);
}
}
-
- Promise.resolve(indexPattern.destroy()).then(function () {
- history.push('');
- });
+ if (indexPattern.id) {
+ Promise.resolve(data.indexPatterns.delete(indexPattern.id)).then(function () {
+ history.push('');
+ });
+ }
}
overlays.openConfirm('', confirmModalOptionsDelete).then((isConfirmed) => {
diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap
new file mode 100644
index 0000000000000..e32425a095429
--- /dev/null
+++ b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"foo bar foobar "`;
diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap
new file mode 100644
index 0000000000000..dbc3dd1202cbd
--- /dev/null
+++ b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"CN IN US DE BR "`;
+
+exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"CN IN US DE BR "`;
+
+exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"CN IN US DE BR "`;
diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js
similarity index 72%
rename from src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud.js
rename to src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js
index 35c7b77687b94..89a6a67bcb2fb 100644
--- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_tagcloud/tag_cloud.js
+++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js
@@ -17,22 +17,27 @@
* under the License.
*/
-import expect from '@kbn/expect';
import _ from 'lodash';
import d3 from 'd3';
+import 'jest-canvas-mock';
import { fromNode, delay } from 'bluebird';
-import { ImageComparator } from 'test_utils/image_comparator';
-import simpleloadPng from './simpleload.png';
+import { TagCloud } from './tag_cloud';
+import { setHTMLElementOffset, setSVGElementGetBBox } from '../../../../test_utils/public';
-// Replace with mock when converting to jest tests
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { seedColors } from '../../../../../../plugins/charts/public/services/colors/seed_colors';
-// Will be replaced with new path when tests are moved
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import { TagCloud } from '../../../../../../plugins/vis_type_tagcloud/public/components/tag_cloud';
+describe('tag cloud tests', () => {
+ let SVGElementGetBBoxSpyInstance;
+ let HTMLElementOffsetMockInstance;
+
+ beforeEach(() => {
+ setupDOM();
+ });
+
+ afterEach(() => {
+ SVGElementGetBBoxSpyInstance.mockRestore();
+ HTMLElementOffsetMockInstance.mockRestore();
+ });
-describe('tag cloud tests', function () {
const minValue = 1;
const maxValue = 9;
const midValue = (minValue + maxValue) / 2;
@@ -100,16 +105,15 @@ describe('tag cloud tests', function () {
let domNode;
let tagCloud;
- const colorScale = d3.scale.ordinal().range(seedColors);
+ const colorScale = d3.scale
+ .ordinal()
+ .range(['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d']);
function setupDOM() {
domNode = document.createElement('div');
- domNode.style.top = '0';
- domNode.style.left = '0';
- domNode.style.width = '512px';
- domNode.style.height = '512px';
- domNode.style.position = 'fixed';
- domNode.style['pointer-events'] = 'none';
+ SVGElementGetBBoxSpyInstance = setSVGElementGetBBox();
+ HTMLElementOffsetMockInstance = setHTMLElementOffset(512, 512);
+
document.body.appendChild(domNode);
}
@@ -126,42 +130,39 @@ describe('tag cloud tests', function () {
sqrtScaleTest,
biggerFontTest,
trimDataTest,
- ].forEach(function (test) {
+ ].forEach(function (currentTest) {
describe(`should position elements correctly for options: ${JSON.stringify(
- test.options
- )}`, function () {
- beforeEach(async function () {
- setupDOM();
+ currentTest.options
+ )}`, () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
- tagCloud.setData(test.data);
- tagCloud.setOptions(test.options);
+ tagCloud.setData(currentTest.data);
+ tagCloud.setOptions(currentTest.options);
await fromNode((cb) => tagCloud.once('renderComplete', cb));
});
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
- verifyTagProperties(test.expected, textElements, tagCloud);
+ verifyTagProperties(currentTest.expected, textElements, tagCloud);
})
);
});
});
- [5, 100, 200, 300, 500].forEach(function (timeout) {
- describe(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, function () {
- beforeEach(async function () {
- setupDOM();
-
+ [5, 100, 200, 300, 500].forEach((timeout) => {
+ describe(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, () => {
+ beforeEach(async () => {
//TagCloud takes at least 600ms to complete (due to d3 animation)
//renderComplete should only notify at the last one
tagCloud = new TagCloud(domNode, colorScale);
@@ -176,16 +177,16 @@ describe('tag cloud tests', function () {
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
})
@@ -193,9 +194,8 @@ describe('tag cloud tests', function () {
});
});
- describe('should use the latest state before notifying (when modifying options multiple times)', function () {
- beforeEach(async function () {
- setupDOM();
+ describe('should use the latest state before notifying (when modifying options multiple times)', () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
@@ -205,53 +205,53 @@ describe('tag cloud tests', function () {
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
})
);
});
- describe('should use the latest state before notifying (when modifying data multiple times)', function () {
- beforeEach(async function () {
- setupDOM();
+ describe('should use the latest state before notifying (when modifying data multiple times)', () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
tagCloud.setData(trimDataTest.data);
+
await fromNode((cb) => tagCloud.once('renderComplete', cb));
});
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
verifyTagProperties(trimDataTest.expected, textElements, tagCloud);
})
);
});
- describe('should not get multiple render-events', function () {
+ describe('should not get multiple render-events', () => {
let counter;
- beforeEach(function () {
+ beforeEach(() => {
counter = 0;
- setupDOM();
+
return new Promise((resolve, reject) => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
@@ -281,31 +281,32 @@ describe('tag cloud tests', function () {
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
})
);
});
- describe('should show correct data when state-updates are interleaved with resize event', function () {
- beforeEach(async function () {
- setupDOM();
+ describe('should show correct data when state-updates are interleaved with resize event', () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(logScaleTest.data);
tagCloud.setOptions(logScaleTest.options);
await delay(1000); //let layout run
- domNode.style.width = '600px';
- domNode.style.height = '600px';
+
+ SVGElementGetBBoxSpyInstance.mockRestore();
+ SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(600, 600);
+
tagCloud.resize(); //triggers new layout
setTimeout(() => {
//change the options at the very end too
@@ -317,26 +318,23 @@ describe('tag cloud tests', function () {
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
- it(
+ test(
'positions should be ok',
- handleExpectedBlip(function () {
+ handleExpectedBlip(() => {
const textElements = domNode.querySelectorAll('text');
verifyTagProperties(baseTest.expected, textElements, tagCloud);
})
);
});
- describe(`should not put elements in view when container is too small`, function () {
- beforeEach(async function () {
- setupDOM();
- domNode.style.width = '1px';
- domNode.style.height = '1px';
+ describe(`should not put elements in view when container is too small`, () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
@@ -345,10 +343,10 @@ describe('tag cloud tests', function () {
afterEach(teardownDOM);
- it('completeness should not be ok', function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
+ test('completeness should not be ok', () => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE);
});
- it('positions should not be ok', function () {
+ test('positions should not be ok', () => {
const textElements = domNode.querySelectorAll('text');
for (let i = 0; i < textElements; i++) {
const bbox = textElements[i].getBoundingClientRect();
@@ -357,96 +355,73 @@ describe('tag cloud tests', function () {
});
});
- describe(`tags should fit after making container bigger`, function () {
- beforeEach(async function () {
- setupDOM();
- domNode.style.width = '1px';
- domNode.style.height = '1px';
-
+ describe(`tags should fit after making container bigger`, () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
await fromNode((cb) => tagCloud.once('renderComplete', cb));
//make bigger
- domNode.style.width = '512px';
- domNode.style.height = '512px';
+ tagCloud._size = [600, 600];
tagCloud.resize();
await fromNode((cb) => tagCloud.once('renderComplete', cb));
});
afterEach(teardownDOM);
- it(
+ test(
'completeness should be ok',
- handleExpectedBlip(function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
+ handleExpectedBlip(() => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
})
);
});
- describe(`tags should no longer fit after making container smaller`, function () {
- beforeEach(async function () {
- setupDOM();
+ describe(`tags should no longer fit after making container smaller`, () => {
+ beforeEach(async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
await fromNode((cb) => tagCloud.once('renderComplete', cb));
//make smaller
- domNode.style.width = '1px';
- domNode.style.height = '1px';
+ tagCloud._size = [];
tagCloud.resize();
await fromNode((cb) => tagCloud.once('renderComplete', cb));
});
afterEach(teardownDOM);
- it('completeness should not be ok', function () {
- expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
+ test('completeness should not be ok', () => {
+ expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE);
});
});
- describe('tagcloudscreenshot', function () {
- let imageComparator;
- beforeEach(async function () {
- setupDOM();
- imageComparator = new ImageComparator();
- });
-
- afterEach(() => {
- imageComparator.destroy();
- teardownDOM();
- });
+ describe('tagcloudscreenshot', () => {
+ afterEach(teardownDOM);
- it('should render simple image', async function () {
+ test('should render simple image', async () => {
tagCloud = new TagCloud(domNode, colorScale);
tagCloud.setData(baseTest.data);
tagCloud.setOptions(baseTest.options);
await fromNode((cb) => tagCloud.once('renderComplete', cb));
- const mismatchedPixels = await imageComparator.compareDOMContents(
- domNode.innerHTML,
- 512,
- 512,
- simpleloadPng,
- 0.5
- );
- expect(mismatchedPixels).to.be.lessThan(64);
+ expect(domNode.innerHTML).toMatchSnapshot();
});
});
function verifyTagProperties(expectedValues, actualElements, tagCloud) {
- expect(actualElements.length).to.equal(expectedValues.length);
+ expect(actualElements.length).toEqual(expectedValues.length);
expectedValues.forEach((test, index) => {
try {
- expect(actualElements[index].style.fontSize).to.equal(test.fontSize);
+ expect(actualElements[index].style.fontSize).toEqual(test.fontSize);
} catch (e) {
throw new Error('fontsize is not correct: ' + e.message);
}
try {
- expect(actualElements[index].innerHTML).to.equal(test.text);
+ expect(actualElements[index].innerHTML).toEqual(test.text);
} catch (e) {
throw new Error('fontsize is not correct: ' + e.message);
}
@@ -470,14 +445,14 @@ describe('tag cloud tests', function () {
debugInfo: ${JSON.stringify(tagCloud.getDebugInfo())}`;
try {
- expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).to.be(shouldBeInside);
+ expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).toBe(shouldBeInside);
} catch (e) {
throw new Error(
'top boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message
);
}
try {
- expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).to.be(shouldBeInside);
+ expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).toBe(shouldBeInside);
} catch (e) {
throw new Error(
'bottom boundary of tag should have been ' +
@@ -486,14 +461,14 @@ describe('tag cloud tests', function () {
);
}
try {
- expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).to.be(shouldBeInside);
+ expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).toBe(shouldBeInside);
} catch (e) {
throw new Error(
'left boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message
);
}
try {
- expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).to.be(shouldBeInside);
+ expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).toBe(shouldBeInside);
} catch (e) {
throw new Error(
'right boundary of tag should have been ' +
@@ -532,7 +507,7 @@ describe('tag cloud tests', function () {
}
function handleExpectedBlip(assertion) {
- return function () {
+ return () => {
if (!shouldAssert()) {
return;
}
diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js
new file mode 100644
index 0000000000000..7f96066c16076
--- /dev/null
+++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js
@@ -0,0 +1,176 @@
+/*
+ * 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 'jest-canvas-mock';
+
+import { createTagCloudVisTypeDefinition } from '../tag_cloud_type';
+import { createTagCloudVisualization } from './tag_cloud_visualization';
+import { setFormatService } from '../services';
+import { dataPluginMock } from '../../../data/public/mocks';
+import { setHTMLElementOffset, setSVGElementGetBBox } from '../../../../test_utils/public';
+
+const seedColors = ['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d'];
+
+describe('TagCloudVisualizationTest', () => {
+ let domNode;
+ let vis;
+ let SVGElementGetBBoxSpyInstance;
+ let HTMLElementOffsetMockInstance;
+
+ const dummyTableGroup = {
+ columns: [
+ {
+ id: 'col-0',
+ title: 'geo.dest: Descending',
+ },
+ {
+ id: 'col-1',
+ title: 'Count',
+ },
+ ],
+ rows: [
+ { 'col-0': 'CN', 'col-1': 26 },
+ { 'col-0': 'IN', 'col-1': 17 },
+ { 'col-0': 'US', 'col-1': 6 },
+ { 'col-0': 'DE', 'col-1': 4 },
+ { 'col-0': 'BR', 'col-1': 3 },
+ ],
+ };
+ const TagCloudVisualization = createTagCloudVisualization({
+ colors: {
+ seedColors,
+ },
+ });
+
+ const originTransformSVGElement = window.SVGElement.prototype.transform;
+
+ beforeAll(() => {
+ setFormatService(dataPluginMock.createStartContract().fieldFormats);
+ Object.defineProperties(window.SVGElement.prototype, {
+ transform: {
+ get: () => ({
+ baseVal: {
+ consolidate: () => {},
+ },
+ }),
+ configurable: true,
+ },
+ });
+ });
+
+ afterAll(() => {
+ SVGElementGetBBoxSpyInstance.mockRestore();
+ HTMLElementOffsetMockInstance.mockRestore();
+ window.SVGElement.prototype.transform = originTransformSVGElement;
+ });
+
+ describe('TagCloudVisualization - basics', () => {
+ beforeEach(async () => {
+ const visType = createTagCloudVisTypeDefinition({ colors: seedColors });
+ setupDOM(512, 512);
+
+ vis = {
+ type: visType,
+ params: {
+ bucket: { accessor: 0, format: {} },
+ metric: { accessor: 0, format: {} },
+ scale: 'linear',
+ orientation: 'single',
+ },
+ data: {},
+ };
+ });
+
+ test('simple draw', async () => {
+ const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
+
+ await tagcloudVisualization.render(dummyTableGroup, vis.params, {
+ resize: false,
+ params: true,
+ aggs: true,
+ data: true,
+ uiState: false,
+ });
+
+ const svgNode = domNode.querySelector('svg');
+ expect(svgNode.outerHTML).toMatchSnapshot();
+ });
+
+ test('with resize', async () => {
+ const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
+ await tagcloudVisualization.render(dummyTableGroup, vis.params, {
+ resize: false,
+ params: true,
+ aggs: true,
+ data: true,
+ uiState: false,
+ });
+
+ domNode.style.width = '256px';
+ domNode.style.height = '368px';
+ await tagcloudVisualization.render(dummyTableGroup, vis.params, {
+ resize: true,
+ params: false,
+ aggs: false,
+ data: false,
+ uiState: false,
+ });
+
+ const svgNode = domNode.querySelector('svg');
+ expect(svgNode.outerHTML).toMatchSnapshot();
+ });
+
+ test('with param change', async function () {
+ const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
+ await tagcloudVisualization.render(dummyTableGroup, vis.params, {
+ resize: false,
+ params: true,
+ aggs: true,
+ data: true,
+ uiState: false,
+ });
+
+ SVGElementGetBBoxSpyInstance.mockRestore();
+ SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(256, 368);
+
+ HTMLElementOffsetMockInstance.mockRestore();
+ HTMLElementOffsetMockInstance = setHTMLElementOffset(256, 386);
+
+ vis.params.orientation = 'right angled';
+ vis.params.minFontSize = 70;
+ await tagcloudVisualization.render(dummyTableGroup, vis.params, {
+ resize: true,
+ params: true,
+ aggs: false,
+ data: false,
+ uiState: false,
+ });
+
+ const svgNode = domNode.querySelector('svg');
+ expect(svgNode.outerHTML).toMatchSnapshot();
+ });
+ });
+
+ function setupDOM(width, height) {
+ domNode = document.createElement('div');
+
+ HTMLElementOffsetMockInstance = setHTMLElementOffset(width, height);
+ SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(width, height);
+ }
+});
diff --git a/src/test_utils/public/helpers/index.ts b/src/test_utils/public/helpers/index.ts
index 79dc29e83bc3b..c8447743ee287 100644
--- a/src/test_utils/public/helpers/index.ts
+++ b/src/test_utils/public/helpers/index.ts
@@ -24,3 +24,5 @@ export { WithStore } from './redux_helpers';
export { WithMemoryRouter, WithRoute, reactRouterMock } from './router_helpers';
export * from './utils';
+
+export { setSVGElementGetBBox, setHTMLElementOffset } from './jsdom_svg_mocks';
diff --git a/src/test_utils/public/helpers/jsdom_svg_mocks.ts b/src/test_utils/public/helpers/jsdom_svg_mocks.ts
new file mode 100644
index 0000000000000..dbc8266f663f1
--- /dev/null
+++ b/src/test_utils/public/helpers/jsdom_svg_mocks.ts
@@ -0,0 +1,57 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const setSVGElementGetBBox = (
+ width: number,
+ height: number,
+ x: number = 0,
+ y: number = 0
+) => {
+ const SVGElementPrototype = SVGElement.prototype as any;
+ const originalGetBBox = SVGElementPrototype.getBBox;
+
+ // getBBox is not in the SVGElement.prototype object by default, so we cannot use jest.spyOn for that case
+ SVGElementPrototype.getBBox = jest.fn(() => ({
+ x,
+ y,
+ width,
+ height,
+ }));
+
+ return {
+ mockRestore: () => {
+ SVGElementPrototype.getBBox = originalGetBBox;
+ },
+ };
+};
+
+export const setHTMLElementOffset = (width: number, height: number) => {
+ const offsetWidthSpy = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get');
+ offsetWidthSpy.mockReturnValue(width);
+
+ const offsetHeightSpy = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get');
+ offsetHeightSpy.mockReturnValue(height);
+
+ return {
+ mockRestore: () => {
+ offsetWidthSpy.mockRestore();
+ offsetHeightSpy.mockRestore();
+ },
+ };
+};
diff --git a/src/test_utils/public/index.ts b/src/test_utils/public/index.ts
new file mode 100644
index 0000000000000..4f46dfe1578db
--- /dev/null
+++ b/src/test_utils/public/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { setSVGElementGetBBox, setHTMLElementOffset } from './helpers';
diff --git a/test/plugin_functional/plugins/index_patterns/server/plugin.ts b/test/plugin_functional/plugins/index_patterns/server/plugin.ts
index ffc70136ccffa..d6a4fdd67b0a1 100644
--- a/test/plugin_functional/plugins/index_patterns/server/plugin.ts
+++ b/test/plugin_functional/plugins/index_patterns/server/plugin.ts
@@ -96,8 +96,7 @@ export class IndexPatternsTestPlugin
const [, { data }] = await core.getStartServices();
const id = (req.params as Record).id;
const service = await data.indexPatterns.indexPatternsServiceFactory(req);
- const ip = await service.get(id);
- await ip.destroy();
+ await service.delete(id);
return res.ok();
}
);
diff --git a/x-pack/index.js b/x-pack/index.js
index 2d2e42650cfa7..66fe05e8f035e 100644
--- a/x-pack/index.js
+++ b/x-pack/index.js
@@ -9,15 +9,7 @@ import { monitoring } from './legacy/plugins/monitoring';
import { security } from './legacy/plugins/security';
import { beats } from './legacy/plugins/beats_management';
import { spaces } from './legacy/plugins/spaces';
-import { ingestManager } from './legacy/plugins/ingest_manager';
module.exports = function (kibana) {
- return [
- xpackMain(kibana),
- monitoring(kibana),
- spaces(kibana),
- security(kibana),
- ingestManager(kibana),
- beats(kibana),
- ];
+ return [xpackMain(kibana), monitoring(kibana), spaces(kibana), security(kibana), beats(kibana)];
};
diff --git a/x-pack/legacy/plugins/ingest_manager/index.ts b/x-pack/legacy/plugins/ingest_manager/index.ts
deleted file mode 100644
index 2b20bf16f2400..0000000000000
--- a/x-pack/legacy/plugins/ingest_manager/index.ts
+++ /dev/null
@@ -1,14 +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 { resolve } from 'path';
-
-export function ingestManager(kibana: any) {
- return new kibana.Plugin({
- id: 'ingestManager',
- require: ['kibana', 'elasticsearch', 'xpack_main'],
- publicDir: resolve(__dirname, '../../../plugins/ingest_manager/public'),
- });
-}
diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts
index 9fc36baf8d535..bd6e022353fad 100644
--- a/x-pack/plugins/actions/server/actions_client.ts
+++ b/x-pack/plugins/actions/server/actions_client.ts
@@ -159,7 +159,7 @@ export class ActionsClient {
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
- const result = await this.savedObjectsClient.update('action', id, {
+ const result = await this.savedObjectsClient.update('action', id, {
actionTypeId,
name,
config: validatedActionTypeConfig as SavedObjectAttributes,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/api.ts b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts
index 6dc8a9cc9af6a..de4b7edaed3da 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts
@@ -41,7 +41,7 @@ const pushToServiceHandler = async ({
}
const fields = prepareFieldsForTransformation({
- params,
+ externalCase: params.externalCase,
mapping,
defaultPipes,
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts
index 33b2ad6d18684..f47686c911ff0 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts
@@ -67,7 +67,7 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
- caseId: schema.string(),
+ savedObjectId: schema.string(),
title: schema.string(),
description: schema.nullable(schema.string()),
comments: schema.nullable(schema.arrayOf(CommentSchema)),
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts
index 992b2cb16fb06..de96864d0b295 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts
@@ -144,7 +144,7 @@ export interface PipedField {
}
export interface PrepareFieldsForTransformArgs {
- params: PushToServiceApiParams;
+ externalCase: Record;
mapping: Map;
defaultPipes?: string[];
}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts
index 017fc73efae20..dbb18fa5c695c 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts
@@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import axios from 'axios';
-
import {
normalizeMapping,
buildMap,
@@ -13,19 +11,11 @@ import {
prepareFieldsForTransformation,
transformFields,
transformComments,
- addTimeZoneToDate,
- throwIfNotAlive,
- request,
- patch,
- getErrorMessage,
} from './utils';
import { SUPPORTED_SOURCE_FIELDS } from './constants';
import { Comment, MapRecord, PushToServiceApiParams } from './types';
-jest.mock('axios');
-const axiosMock = (axios as unknown) as jest.Mock;
-
const mapping: MapRecord[] = [
{ source: 'title', target: 'short_description', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'append' },
@@ -63,7 +53,7 @@ const maliciousMapping: MapRecord[] = [
];
const fullParams: PushToServiceApiParams = {
- caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
+ savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
title: 'a title',
description: 'a description',
createdAt: '2020-03-13T08:34:53.450Z',
@@ -132,7 +122,7 @@ describe('buildMap', () => {
describe('mapParams', () => {
test('maps params correctly', () => {
const params = {
- caseId: '123',
+ savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
@@ -148,7 +138,7 @@ describe('mapParams', () => {
test('do not add fields not in mapping', () => {
const params = {
- caseId: '123',
+ savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
@@ -164,7 +154,7 @@ describe('mapParams', () => {
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
- params: fullParams,
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
});
expect(res).toEqual([
@@ -185,7 +175,7 @@ describe('prepareFieldsForTransformation', () => {
test('prepare fields with default pipes', () => {
const res = prepareFieldsForTransformation({
- params: fullParams,
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['myTestPipe'],
});
@@ -209,7 +199,7 @@ describe('prepareFieldsForTransformation', () => {
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
- params: fullParams,
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
});
@@ -226,14 +216,7 @@ describe('transformFields', () => {
test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
- params: {
- ...fullParams,
- updatedAt: '2020-03-15T08:34:53.450Z',
- updatedBy: {
- username: 'anotherUser',
- fullName: 'Another User',
- },
- },
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
@@ -262,7 +245,7 @@ describe('transformFields', () => {
test('add newline character to descripton', () => {
const fields = prepareFieldsForTransformation({
- params: fullParams,
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
@@ -280,7 +263,7 @@ describe('transformFields', () => {
test('append username if fullname is undefined when create', () => {
const fields = prepareFieldsForTransformation({
- params: fullParams,
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
});
@@ -300,14 +283,7 @@ describe('transformFields', () => {
test('append username if fullname is undefined when update', () => {
const fields = prepareFieldsForTransformation({
- params: {
- ...fullParams,
- updatedAt: '2020-03-15T08:34:53.450Z',
- updatedBy: {
- username: 'anotherUser',
- fullName: 'Another User',
- },
- },
+ externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
@@ -479,98 +455,3 @@ describe('transformComments', () => {
]);
});
});
-
-describe('addTimeZoneToDate', () => {
- test('adds timezone with default', () => {
- const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z');
- expect(date).toBe('2020-04-14T15:01:55.456Z GMT');
- });
-
- test('adds timezone correctly', () => {
- const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z', 'PST');
- expect(date).toBe('2020-04-14T15:01:55.456Z PST');
- });
-});
-
-describe('throwIfNotAlive ', () => {
- test('throws correctly when status is invalid', async () => {
- expect(() => {
- throwIfNotAlive(404, 'application/json');
- }).toThrow('Instance is not alive.');
- });
-
- test('throws correctly when content is invalid', () => {
- expect(() => {
- throwIfNotAlive(200, 'application/html');
- }).toThrow('Instance is not alive.');
- });
-
- test('do NOT throws with custom validStatusCodes', async () => {
- expect(() => {
- throwIfNotAlive(404, 'application/json', [404]);
- }).not.toThrow('Instance is not alive.');
- });
-});
-
-describe('request', () => {
- beforeEach(() => {
- axiosMock.mockImplementation(() => ({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: { incidentId: '123' },
- }));
- });
-
- test('it fetch correctly with defaults', async () => {
- const res = await request({ axios, url: '/test' });
-
- expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {} });
- expect(res).toEqual({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: { incidentId: '123' },
- });
- });
-
- test('it fetch correctly', async () => {
- const res = await request({ axios, url: '/test', method: 'post', data: { id: '123' } });
-
- expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' } });
- expect(res).toEqual({
- status: 200,
- headers: { 'content-type': 'application/json' },
- data: { incidentId: '123' },
- });
- });
-
- test('it throws correctly', async () => {
- axiosMock.mockImplementation(() => ({
- status: 404,
- headers: { 'content-type': 'application/json' },
- data: { incidentId: '123' },
- }));
-
- await expect(request({ axios, url: '/test' })).rejects.toThrow();
- });
-});
-
-describe('patch', () => {
- beforeEach(() => {
- axiosMock.mockImplementation(() => ({
- status: 200,
- headers: { 'content-type': 'application/json' },
- }));
- });
-
- test('it fetch correctly', async () => {
- await patch({ axios, url: '/test', data: { id: '123' } });
- expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' } });
- });
-});
-
-describe('getErrorMessage', () => {
- test('it returns the correct error message', () => {
- const msg = getErrorMessage('My connector name', 'An error has occurred');
- expect(msg).toBe('[Action][My connector name]: An error has occurred');
- });
-});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
index 2d81c2bf4e15f..676a4776d0055 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
@@ -6,7 +6,6 @@
import { curry, flow, get } from 'lodash';
import { schema } from '@kbn/config-schema';
-import { AxiosInstance, Method, AxiosResponse } from 'axios';
import { ActionTypeExecutorOptions, ActionTypeExecutorResult, ActionType } from '../../types';
@@ -134,65 +133,18 @@ export const createConnector = ({
});
};
-export const throwIfNotAlive = (
- status: number,
- contentType: string,
- validStatusCodes: number[] = [200, 201, 204]
-) => {
- if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) {
- throw new Error('Instance is not alive.');
- }
-};
-
-export const request = async ({
- axios,
- url,
- method = 'get',
- data,
-}: {
- axios: AxiosInstance;
- url: string;
- method?: Method;
- data?: T;
-}): Promise => {
- const res = await axios(url, { method, data: data ?? {} });
- throwIfNotAlive(res.status, res.headers['content-type']);
- return res;
-};
-
-export const patch = async ({
- axios,
- url,
- data,
-}: {
- axios: AxiosInstance;
- url: string;
- data: T;
-}): Promise => {
- return request({
- axios,
- url,
- method: 'patch',
- data,
- });
-};
-
-export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => {
- return `${date} ${timezone}`;
-};
-
export const prepareFieldsForTransformation = ({
- params,
+ externalCase,
mapping,
defaultPipes = ['informationCreated'],
}: PrepareFieldsForTransformArgs): PipedField[] => {
- return Object.keys(params.externalCase)
+ return Object.keys(externalCase)
.filter((p) => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing')
.map((p) => {
const actionType = mapping.get(p)?.actionType ?? 'nothing';
return {
key: p,
- value: params.externalCase[p],
+ value: externalCase[p],
actionType,
pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes,
};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts
index 6ba4d7cfc7de0..0020161789d71 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts
@@ -32,6 +32,6 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
- actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities }));
+ actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getJiraActionType({ configurationUtilities }));
}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
index 3ae0e9db36de0..709d490a5227f 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
@@ -88,7 +88,7 @@ mapping.set('summary', {
});
const executorParams: ExecutorSubActionPushParams = {
- caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
+ savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
index b9225b043d526..3de3926b7d821 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
@@ -7,12 +7,12 @@
import axios from 'axios';
import { createExternalService } from './service';
-import * as utils from '../case/utils';
+import * as utils from '../lib/axios_utils';
import { ExternalService } from '../case/types';
jest.mock('axios');
-jest.mock('../case/utils', () => {
- const originalUtils = jest.requireActual('../case/utils');
+jest.mock('../lib/axios_utils', () => {
+ const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
index ff22b8368e7dd..240b645c3a7dc 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
@@ -16,7 +16,7 @@ import {
} from './types';
import * as i18n from './translations';
-import { getErrorMessage, request } from '../case/utils';
+import { request, getErrorMessage } from '../lib/axios_utils';
const VERSION = '2';
const BASE_URL = `rest/api/${VERSION}`;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts
new file mode 100644
index 0000000000000..4a52ae60bcdda
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.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 axios from 'axios';
+import { addTimeZoneToDate, throwIfNotAlive, request, patch, getErrorMessage } from './axios_utils';
+jest.mock('axios');
+const axiosMock = (axios as unknown) as jest.Mock;
+
+describe('addTimeZoneToDate', () => {
+ test('adds timezone with default', () => {
+ const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z');
+ expect(date).toBe('2020-04-14T15:01:55.456Z GMT');
+ });
+
+ test('adds timezone correctly', () => {
+ const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z', 'PST');
+ expect(date).toBe('2020-04-14T15:01:55.456Z PST');
+ });
+});
+
+describe('throwIfNotAlive ', () => {
+ test('throws correctly when status is invalid', async () => {
+ expect(() => {
+ throwIfNotAlive(404, 'application/json');
+ }).toThrow('Instance is not alive.');
+ });
+
+ test('throws correctly when content is invalid', () => {
+ expect(() => {
+ throwIfNotAlive(200, 'application/html');
+ }).toThrow('Instance is not alive.');
+ });
+
+ test('do NOT throws with custom validStatusCodes', async () => {
+ expect(() => {
+ throwIfNotAlive(404, 'application/json', [404]);
+ }).not.toThrow('Instance is not alive.');
+ });
+});
+
+describe('request', () => {
+ beforeEach(() => {
+ axiosMock.mockImplementation(() => ({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: { incidentId: '123' },
+ }));
+ });
+
+ test('it fetch correctly with defaults', async () => {
+ const res = await request({ axios, url: '/test' });
+
+ expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {} });
+ expect(res).toEqual({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: { incidentId: '123' },
+ });
+ });
+
+ test('it fetch correctly', async () => {
+ const res = await request({ axios, url: '/test', method: 'post', data: { id: '123' } });
+
+ expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' } });
+ expect(res).toEqual({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ data: { incidentId: '123' },
+ });
+ });
+
+ test('it throws correctly', async () => {
+ axiosMock.mockImplementation(() => ({
+ status: 404,
+ headers: { 'content-type': 'application/json' },
+ data: { incidentId: '123' },
+ }));
+
+ await expect(request({ axios, url: '/test' })).rejects.toThrow();
+ });
+});
+
+describe('patch', () => {
+ beforeEach(() => {
+ axiosMock.mockImplementation(() => ({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }));
+ });
+
+ test('it fetch correctly', async () => {
+ await patch({ axios, url: '/test', data: { id: '123' } });
+ expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' } });
+ });
+});
+
+describe('getErrorMessage', () => {
+ test('it returns the correct error message', () => {
+ const msg = getErrorMessage('My connector name', 'An error has occurred');
+ expect(msg).toBe('[Action][My connector name]: An error has occurred');
+ });
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts
new file mode 100644
index 0000000000000..d527cf632bace
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { AxiosInstance, Method, AxiosResponse } from 'axios';
+
+export const throwIfNotAlive = (
+ status: number,
+ contentType: string,
+ validStatusCodes: number[] = [200, 201, 204]
+) => {
+ if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) {
+ throw new Error('Instance is not alive.');
+ }
+};
+
+export const request = async ({
+ axios,
+ url,
+ method = 'get',
+ data,
+ params,
+}: {
+ axios: AxiosInstance;
+ url: string;
+ method?: Method;
+ data?: T;
+ params?: unknown;
+}): Promise => {
+ const res = await axios(url, { method, data: data ?? {}, params });
+ throwIfNotAlive(res.status, res.headers['content-type']);
+ return res;
+};
+
+export const patch = async ({
+ axios,
+ url,
+ data,
+}: {
+ axios: AxiosInstance;
+ url: string;
+ data: T;
+}): Promise => {
+ return request({
+ axios,
+ url,
+ method: 'patch',
+ data,
+ });
+};
+
+export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => {
+ return `${date} ${timezone}`;
+};
+
+export const getErrorMessage = (connector: string, msg: string) => {
+ return `[Action][${connector}]: ${msg}`;
+};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
index 86a8318841271..7daf14e99f254 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
@@ -4,9 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { api } from '../case/api';
+import { Logger } from '../../../../../../src/core/server';
import { externalServiceMock, mapping, apiParams } from './mocks';
-import { ExternalService } from '../case/types';
+import { ExternalService } from './types';
+import { api } from './api';
+let mockedLogger: jest.Mocked;
describe('api', () => {
let externalService: jest.Mocked;
@@ -24,7 +26,13 @@ describe('api', () => {
describe('create incident', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -46,7 +54,13 @@ describe('api', () => {
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -57,8 +71,14 @@ describe('api', () => {
});
test('it calls createIncident correctly', async () => {
- const params = { ...apiParams, externalId: null };
- await api.pushToService({ externalService, mapping, params });
+ const params = { ...apiParams, externalId: null, comments: undefined };
+ await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
@@ -71,53 +91,49 @@ describe('api', () => {
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
- test('it calls createComment correctly', async () => {
+ test('it calls updateIncident correctly', async () => {
const params = { ...apiParams, externalId: null };
- await api.pushToService({ externalService, mapping, params });
- expect(externalService.createComment).toHaveBeenCalledTimes(2);
- expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
- incidentId: 'incident-1',
- comment: {
- commentId: 'case-comment-1',
- comment: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
- createdAt: '2020-03-13T08:34:53.450Z',
- createdBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
- updatedAt: '2020-03-13T08:34:53.450Z',
- updatedBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
+ await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
+ expect(externalService.updateIncident).toHaveBeenCalledTimes(2);
+ expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
+ incident: {
+ comments: 'A comment',
+ description:
+ 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
+ short_description:
+ 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
},
- field: 'comments',
+ incidentId: 'incident-1',
});
- expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
- incidentId: 'incident-1',
- comment: {
- commentId: 'case-comment-2',
- comment: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
- createdAt: '2020-03-13T08:34:53.450Z',
- createdBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
- updatedAt: '2020-03-13T08:34:53.450Z',
- updatedBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
+ expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
+ incident: {
+ comments: 'Another comment',
+ description:
+ 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
+ short_description:
+ 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
},
- field: 'comments',
+ incidentId: 'incident-1',
});
});
});
describe('update incident', () => {
test('it updates an incident', async () => {
- const res = await api.pushToService({ externalService, mapping, params: apiParams });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-2',
@@ -139,7 +155,13 @@ describe('api', () => {
test('it updates an incident without comments', async () => {
const params = { ...apiParams, comments: [] };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-2',
@@ -151,7 +173,13 @@ describe('api', () => {
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
- await api.pushToService({ externalService, mapping, params });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
@@ -165,46 +193,35 @@ describe('api', () => {
expect(externalService.createIncident).not.toHaveBeenCalled();
});
- test('it calls createComment correctly', async () => {
+ test('it calls updateIncident to create a comments correctly', async () => {
const params = { ...apiParams };
- await api.pushToService({ externalService, mapping, params });
- expect(externalService.createComment).toHaveBeenCalledTimes(2);
- expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
- incidentId: 'incident-2',
- comment: {
- commentId: 'case-comment-1',
- comment: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
- createdAt: '2020-03-13T08:34:53.450Z',
- createdBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
- updatedAt: '2020-03-13T08:34:53.450Z',
- updatedBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
+ await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ secrets: {},
+ logger: mockedLogger,
+ });
+ expect(externalService.updateIncident).toHaveBeenCalledTimes(3);
+ expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
+ incident: {
+ description:
+ 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
+ short_description:
+ 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
- field: 'comments',
+ incidentId: 'incident-3',
});
- expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
- incidentId: 'incident-2',
- comment: {
- commentId: 'case-comment-2',
- comment: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
- createdAt: '2020-03-13T08:34:53.450Z',
- createdBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
- updatedAt: '2020-03-13T08:34:53.450Z',
- updatedBy: {
- fullName: 'Elastic User',
- username: 'elastic',
- },
+ expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
+ incident: {
+ comments: 'A comment',
+ description:
+ 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
+ short_description:
+ 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
- field: 'comments',
+ incidentId: 'incident-2',
});
});
});
@@ -231,7 +248,13 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -264,7 +287,13 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -295,7 +324,13 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -328,7 +363,13 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
@@ -356,7 +397,13 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -387,7 +434,13 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -420,7 +473,13 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -451,7 +510,13 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -484,7 +549,13 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -515,8 +586,14 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
- expect(externalService.createComment).not.toHaveBeenCalled();
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ secrets: {},
+ logger: mockedLogger,
+ });
+ expect(externalService.updateIncident).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
index 3db66e5884af4..bd6f88f5efaa9 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
@@ -3,5 +3,145 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+import { flow } from 'lodash';
+import {
+ ExternalServiceParams,
+ PushToServiceApiHandlerArgs,
+ HandshakeApiHandlerArgs,
+ GetIncidentApiHandlerArgs,
+ ExternalServiceApi,
+} from './types';
-export { api } from '../case/api';
+// TODO: to remove, need to support Case
+import { transformers } from '../case/transformers';
+import { PushToServiceResponse, TransformFieldsArgs } from './case_types';
+import { prepareFieldsForTransformation } from '../case/utils';
+
+const handshakeHandler = async ({
+ externalService,
+ mapping,
+ params,
+}: HandshakeApiHandlerArgs) => {};
+const getIncidentHandler = async ({
+ externalService,
+ mapping,
+ params,
+}: GetIncidentApiHandlerArgs) => {};
+
+const pushToServiceHandler = async ({
+ externalService,
+ mapping,
+ params,
+ secrets,
+ logger,
+}: PushToServiceApiHandlerArgs): Promise => {
+ const { externalId, comments } = params;
+ const updateIncident = externalId ? true : false;
+ const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
+ let currentIncident: ExternalServiceParams | undefined;
+ let res: PushToServiceResponse;
+
+ if (externalId) {
+ try {
+ currentIncident = await externalService.getIncident(externalId);
+ } catch (ex) {
+ logger.debug(
+ `Retrieving Incident by id ${externalId} from ServiceNow was failed with exception: ${ex}`
+ );
+ }
+ }
+
+ let incident = {};
+ // TODO: should be removed later but currently keep it for the Case implementation support
+ if (mapping) {
+ const fields = prepareFieldsForTransformation({
+ externalCase: params.externalObject,
+ mapping,
+ defaultPipes,
+ });
+
+ incident = transformFields({
+ params,
+ fields,
+ currentIncident,
+ });
+ } else {
+ incident = { ...params, short_description: params.title, comments: params.comment };
+ }
+
+ if (updateIncident) {
+ res = await externalService.updateIncident({
+ incidentId: externalId,
+ incident,
+ });
+ } else {
+ res = await externalService.createIncident({
+ incident: {
+ ...incident,
+ caller_id: secrets.username,
+ },
+ });
+ }
+
+ // TODO: should temporary keep comments for a Case usage
+ if (
+ comments &&
+ Array.isArray(comments) &&
+ comments.length > 0 &&
+ mapping &&
+ mapping.get('comments')?.actionType !== 'nothing'
+ ) {
+ res.comments = [];
+
+ const fieldsKey = mapping.get('comments')?.target ?? 'comments';
+ for (const currentComment of comments) {
+ await externalService.updateIncident({
+ incidentId: res.id,
+ incident: {
+ ...incident,
+ [fieldsKey]: currentComment.comment,
+ },
+ });
+ res.comments = [
+ ...(res.comments ?? []),
+ {
+ commentId: currentComment.commentId,
+ pushedDate: res.pushedDate,
+ },
+ ];
+ }
+ }
+ return res;
+};
+
+export const transformFields = ({
+ params,
+ fields,
+ currentIncident,
+}: TransformFieldsArgs): Record => {
+ return fields.reduce((prev, cur) => {
+ const transform = flow(...cur.pipes.map((p) => transformers[p]));
+ return {
+ ...prev,
+ [cur.key]: transform({
+ value: cur.value,
+ date: params.updatedAt ?? params.createdAt,
+ user:
+ (params.updatedBy != null
+ ? params.updatedBy.fullName
+ ? params.updatedBy.fullName
+ : params.updatedBy.username
+ : params.createdBy.fullName
+ ? params.createdBy.fullName
+ : params.createdBy.username) ?? '',
+ previousValue: currentIncident ? currentIncident[cur.key] : '',
+ }).value,
+ };
+ }, {});
+};
+
+export const api: ExternalServiceApi = {
+ handshake: handshakeHandler,
+ pushToService: pushToServiceHandler,
+ getIncident: getIncidentHandler,
+};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts
new file mode 100644
index 0000000000000..2df8c8156cde8
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+export const MappingActionType = schema.oneOf([
+ schema.literal('nothing'),
+ schema.literal('overwrite'),
+ schema.literal('append'),
+]);
+
+export const MapRecordSchema = schema.object({
+ source: schema.string(),
+ target: schema.string(),
+ actionType: MappingActionType,
+});
+
+export const IncidentConfigurationSchema = schema.object({
+ mapping: schema.arrayOf(MapRecordSchema),
+});
+
+export const EntityInformation = {
+ createdAt: schema.maybe(schema.string()),
+ createdBy: schema.maybe(schema.any()),
+ updatedAt: schema.nullable(schema.string()),
+ updatedBy: schema.nullable(schema.any()),
+};
+
+export const CommentSchema = schema.object({
+ commentId: schema.string(),
+ comment: schema.string(),
+ ...EntityInformation,
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts
new file mode 100644
index 0000000000000..7e659125af7b2
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { TypeOf } from '@kbn/config-schema';
+import {
+ ExecutorSubActionGetIncidentParamsSchema,
+ ExecutorSubActionHandshakeParamsSchema,
+} from './schema';
+import { IncidentConfigurationSchema, MapRecordSchema } from './case_shema';
+import {
+ PushToServiceApiParams,
+ ExternalServiceIncidentResponse,
+ ExternalServiceParams,
+} from './types';
+
+export interface CreateCommentRequest {
+ [key: string]: string;
+}
+
+export type IncidentConfiguration = TypeOf;
+export type MapRecord = TypeOf;
+
+export interface ExternalServiceCommentResponse {
+ commentId: string;
+ pushedDate: string;
+ externalCommentId?: string;
+}
+
+export type ExecutorSubActionGetIncidentParams = TypeOf<
+ typeof ExecutorSubActionGetIncidentParamsSchema
+>;
+
+export type ExecutorSubActionHandshakeParams = TypeOf<
+ typeof ExecutorSubActionHandshakeParamsSchema
+>;
+
+export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
+ comments?: ExternalServiceCommentResponse[];
+}
+
+export interface PipedField {
+ key: string;
+ value: string;
+ actionType: string;
+ pipes: string[];
+}
+
+export interface TransformFieldsArgs {
+ params: PushToServiceApiParams;
+ fields: PipedField[];
+ currentIncident?: ExternalServiceParams;
+}
+
+export interface TransformerArgs {
+ value: string;
+ date?: string;
+ user?: string;
+ previousValue?: string;
+}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
index dbb536d2fa53d..e62ca465f30f8 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
@@ -4,24 +4,99 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { createConnector } from '../case/utils';
+import { curry } from 'lodash';
+import { schema } from '@kbn/config-schema';
-import { api } from './api';
-import { config } from './config';
import { validate } from './validators';
-import { createExternalService } from './service';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
-} from '../case/schema';
-
-export const getActionType = createConnector({
- api,
- config,
- validate,
- createExternalService,
- validationSchema: {
- config: ExternalIncidentServiceConfiguration,
- secrets: ExternalIncidentServiceSecretConfiguration,
- },
-});
+ ExecutorParamsSchema,
+} from './schema';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
+import { createExternalService } from './service';
+import { api } from './api';
+import { ExecutorParams, ExecutorSubActionPushParams } from './types';
+import * as i18n from './translations';
+import { Logger } from '../../../../../../src/core/server';
+
+// TODO: to remove, need to support Case
+import { buildMap, mapParams } from '../case/utils';
+import { PushToServiceResponse } from './case_types';
+
+interface GetActionTypeParams {
+ logger: Logger;
+ configurationUtilities: ActionsConfigurationUtilities;
+}
+
+// action type definition
+export function getActionType(params: GetActionTypeParams): ActionType {
+ const { logger, configurationUtilities } = params;
+ return {
+ id: '.servicenow',
+ minimumLicenseRequired: 'platinum',
+ name: i18n.NAME,
+ validate: {
+ config: schema.object(ExternalIncidentServiceConfiguration, {
+ validate: curry(validate.config)(configurationUtilities),
+ }),
+ secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
+ validate: curry(validate.secrets)(configurationUtilities),
+ }),
+ params: ExecutorParamsSchema,
+ },
+ executor: curry(executor)({ logger }),
+ };
+}
+
+// action executor
+
+async function executor(
+ { logger }: { logger: Logger },
+ execOptions: ActionTypeExecutorOptions
+): Promise {
+ const { actionId, config, params, secrets } = execOptions;
+ const { subAction, subActionParams } = params as ExecutorParams;
+ let data: PushToServiceResponse | null = null;
+
+ const externalService = createExternalService({
+ config,
+ secrets,
+ });
+
+ if (!api[subAction]) {
+ const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (subAction !== 'pushToService') {
+ const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (subAction === 'pushToService') {
+ const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
+
+ const { comments, externalId, ...restParams } = pushToServiceParams;
+ const mapping = config.incidentConfiguration
+ ? buildMap(config.incidentConfiguration.mapping)
+ : null;
+ const externalObject =
+ config.incidentConfiguration && mapping ? mapParams(restParams, mapping) : {};
+
+ data = await api.pushToService({
+ externalService,
+ mapping,
+ params: { ...pushToServiceParams, externalObject },
+ secrets,
+ logger,
+ });
+
+ logger.debug(`response push to service for incident id: ${data.id}`);
+ }
+
+ return { status: 'ok', data: data ?? {}, actionId };
+}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
index 37228380910b3..5f22fcd4fdc85 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
@@ -4,12 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- ExternalService,
- PushToServiceApiParams,
- ExecutorSubActionPushParams,
- MapRecord,
-} from '../case/types';
+import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
+import { MapRecord } from './case_types';
const createMock = (): jest.Mocked => {
const service = {
@@ -35,22 +31,9 @@ const createMock = (): jest.Mocked => {
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
})
),
- createComment: jest.fn(),
+ findIncidents: jest.fn(),
};
- service.createComment.mockImplementationOnce(() =>
- Promise.resolve({
- commentId: 'case-comment-1',
- pushedDate: '2020-03-10T12:24:20.000Z',
- })
- );
-
- service.createComment.mockImplementationOnce(() =>
- Promise.resolve({
- commentId: 'case-comment-2',
- pushedDate: '2020-03-10T12:24:20.000Z',
- })
- );
return service;
};
@@ -81,7 +64,7 @@ mapping.set('short_description', {
});
const executorParams: ExecutorSubActionPushParams = {
- caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
+ savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
@@ -89,6 +72,10 @@ const executorParams: ExecutorSubActionPushParams = {
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
+ comment: 'test-alert comment',
+ severity: '1',
+ urgency: '2',
+ impact: '1',
comments: [
{
commentId: 'case-comment-1',
@@ -111,7 +98,7 @@ const executorParams: ExecutorSubActionPushParams = {
const apiParams: PushToServiceApiParams = {
...executorParams,
- externalCase: { short_description: 'Incident title', description: 'Incident description' },
+ externalObject: { short_description: 'Incident title', description: 'Incident description' },
};
export { externalServiceMock, mapping, executorParams, apiParams };
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
new file mode 100644
index 0000000000000..82afebaaee445
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema } from '@kbn/config-schema';
+import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from './case_shema';
+
+export const ExternalIncidentServiceConfiguration = {
+ apiUrl: schema.string(),
+ // TODO: to remove - set it optional for the current stage to support Case ServiceNow implementation
+ incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
+ isCaseOwned: schema.maybe(schema.boolean()),
+};
+
+export const ExternalIncidentServiceConfigurationSchema = schema.object(
+ ExternalIncidentServiceConfiguration
+);
+
+export const ExternalIncidentServiceSecretConfiguration = {
+ password: schema.string(),
+ username: schema.string(),
+};
+
+export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
+ ExternalIncidentServiceSecretConfiguration
+);
+
+export const ExecutorSubActionSchema = schema.oneOf([
+ schema.literal('getIncident'),
+ schema.literal('pushToService'),
+ schema.literal('handshake'),
+]);
+
+export const ExecutorSubActionPushParamsSchema = schema.object({
+ savedObjectId: schema.string(),
+ title: schema.string(),
+ description: schema.nullable(schema.string()),
+ comment: schema.nullable(schema.string()),
+ externalId: schema.nullable(schema.string()),
+ severity: schema.nullable(schema.string()),
+ urgency: schema.nullable(schema.string()),
+ impact: schema.nullable(schema.string()),
+ // TODO: remove later - need for support Case push multiple comments
+ comments: schema.maybe(schema.arrayOf(CommentSchema)),
+ ...EntityInformation,
+});
+
+export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
+ externalId: schema.string(),
+});
+
+// Reserved for future implementation
+export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
+
+export const ExecutorParamsSchema = schema.oneOf([
+ schema.object({
+ subAction: schema.literal('getIncident'),
+ subActionParams: ExecutorSubActionGetIncidentParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('handshake'),
+ subActionParams: ExecutorSubActionHandshakeParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('pushToService'),
+ subActionParams: ExecutorSubActionPushParamsSchema,
+ }),
+]);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
index f65cd5430560e..07d60ec9f7a05 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
@@ -7,12 +7,12 @@
import axios from 'axios';
import { createExternalService } from './service';
-import * as utils from '../case/utils';
-import { ExternalService } from '../case/types';
+import * as utils from '../lib/axios_utils';
+import { ExternalService } from './types';
jest.mock('axios');
-jest.mock('../case/utils', () => {
- const originalUtils = jest.requireActual('../case/utils');
+jest.mock('../lib/axios_utils', () => {
+ const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
@@ -198,58 +198,22 @@ describe('ServiceNow service', () => {
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred'
);
});
- });
-
- describe('createComment', () => {
test('it creates the comment correctly', async () => {
patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
+ data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } },
}));
- const res = await service.createComment({
+ const res = await service.updateIncident({
incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'comments',
+ comment: 'comment-1',
});
expect(res).toEqual({
- commentId: 'comment-1',
+ title: 'INC011',
+ id: '11',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11',
});
});
-
- test('it should call request with correct arguments', async () => {
- patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
- }));
-
- await service.createComment({
- incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'my_field',
- });
-
- expect(patchMock).toHaveBeenCalledWith({
- axios,
- url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
- data: { my_field: 'comment' },
- });
- });
-
- test('it should throw an error', async () => {
- patchMock.mockImplementation(() => {
- throw new Error('An error has occurred');
- });
-
- expect(
- service.createComment({
- incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'comments',
- })
- ).rejects.toThrow(
- '[Action][ServiceNow]: Unable to create comment at incident with id 1. Error: An error has occurred'
- );
- });
});
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
index 541fefce2f2ff..2b5204af2eb7d 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
@@ -6,21 +6,14 @@
import axios from 'axios';
-import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types';
-import { addTimeZoneToDate, patch, request, getErrorMessage } from '../case/utils';
+import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
import * as i18n from './translations';
-import {
- ServiceNowPublicConfigurationType,
- ServiceNowSecretConfigurationType,
- CreateIncidentRequest,
- UpdateIncidentRequest,
- CreateCommentRequest,
-} from './types';
+import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types';
+import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils';
const API_VERSION = 'v2';
const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`;
-const COMMENT_URL = `api/now/${API_VERSION}/table/incident`;
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`;
@@ -37,7 +30,6 @@ export const createExternalService = ({
}
const incidentUrl = `${url}/${INCIDENT_URL}`;
- const commentUrl = `${url}/${COMMENT_URL}`;
const axiosInstance = axios.create({
auth: { username, password },
});
@@ -61,13 +53,29 @@ export const createExternalService = ({
}
};
+ const findIncidents = async (params?: Record) => {
+ try {
+ const res = await request({
+ axios: axiosInstance,
+ url: incidentUrl,
+ params,
+ });
+
+ return res.data.result.length > 0 ? { ...res.data.result } : undefined;
+ } catch (error) {
+ throw new Error(
+ getErrorMessage(i18n.NAME, `Unable to find incidents by query. Error: ${error.message}`)
+ );
+ }
+ };
+
const createIncident = async ({ incident }: ExternalServiceParams) => {
try {
- const res = await request({
+ const res = await request({
axios: axiosInstance,
url: `${incidentUrl}`,
method: 'post',
- data: { ...incident },
+ data: { ...(incident as Record) },
});
return {
@@ -85,10 +93,10 @@ export const createExternalService = ({
const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
try {
- const res = await patch({
+ const res = await patch({
axios: axiosInstance,
url: `${incidentUrl}/${incidentId}`,
- data: { ...incident },
+ data: { ...(incident as Record) },
});
return {
@@ -107,32 +115,10 @@ export const createExternalService = ({
}
};
- const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => {
- try {
- const res = await patch({
- axios: axiosInstance,
- url: `${commentUrl}/${incidentId}`,
- data: { [field]: comment.comment },
- });
-
- return {
- commentId: comment.commentId,
- pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(),
- };
- } catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.NAME,
- `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}`
- )
- );
- }
- };
-
return {
getIncident,
createIncident,
updateIncident,
- createComment,
+ findIncidents,
};
};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
index 3d6138169c4cc..05c7d805a1852 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
@@ -6,6 +6,22 @@
import { i18n } from '@kbn/i18n';
-export const NAME = i18n.translate('xpack.actions.builtin.case.servicenowTitle', {
+export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', {
defaultMessage: 'ServiceNow',
});
+
+export const WHITE_LISTED_ERROR = (message: string) =>
+ i18n.translate('xpack.actions.builtin.configuration.apiWhitelistError', {
+ defaultMessage: 'error configuring connector action: {message}',
+ values: {
+ message,
+ },
+ });
+
+// TODO: remove when Case mappings will be removed
+export const MAPPING_EMPTY = i18n.translate(
+ 'xpack.actions.builtin.servicenow.configuration.emptyMapping',
+ {
+ defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
+ }
+);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
index d8476b7dca54a..0db9b6642ea5c 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
@@ -4,18 +4,97 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export {
- ExternalIncidentServiceConfiguration as ServiceNowPublicConfigurationType,
- ExternalIncidentServiceSecretConfiguration as ServiceNowSecretConfigurationType,
-} from '../case/types';
+/* eslint-disable @typescript-eslint/no-explicit-any */
-export interface CreateIncidentRequest {
- summary: string;
- description: string;
-}
+import { TypeOf } from '@kbn/config-schema';
+import {
+ ExternalIncidentServiceConfigurationSchema,
+ ExternalIncidentServiceSecretConfigurationSchema,
+ ExecutorParamsSchema,
+ ExecutorSubActionPushParamsSchema,
+ ExecutorSubActionGetIncidentParamsSchema,
+ ExecutorSubActionHandshakeParamsSchema,
+} from './schema';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import { IncidentConfigurationSchema } from './case_shema';
+import { PushToServiceResponse } from './case_types';
+import { Logger } from '../../../../../../src/core/server';
-export type UpdateIncidentRequest = Partial;
+export type ServiceNowPublicConfigurationType = TypeOf<
+ typeof ExternalIncidentServiceConfigurationSchema
+>;
+export type ServiceNowSecretConfigurationType = TypeOf<
+ typeof ExternalIncidentServiceSecretConfigurationSchema
+>;
export interface CreateCommentRequest {
[key: string]: string;
}
+
+export type ExecutorParams = TypeOf;
+export type ExecutorSubActionPushParams = TypeOf;
+
+export type IncidentConfiguration = TypeOf;
+
+export interface ExternalServiceCredentials {
+ config: Record;
+ secrets: Record;
+}
+
+export interface ExternalServiceValidation {
+ config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void;
+ secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void;
+}
+
+export interface ExternalServiceIncidentResponse {
+ id: string;
+ title: string;
+ url: string;
+ pushedDate: string;
+}
+
+export type ExternalServiceParams = Record;
+
+export interface ExternalService {
+ getIncident: (id: string) => Promise;
+ createIncident: (params: ExternalServiceParams) => Promise;
+ updateIncident: (params: ExternalServiceParams) => Promise;
+ findIncidents: (params?: Record) => Promise;
+}
+
+export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
+ externalObject: Record;
+}
+
+export interface ExternalServiceApiHandlerArgs {
+ externalService: ExternalService;
+ mapping: Map | null;
+}
+
+export type ExecutorSubActionGetIncidentParams = TypeOf<
+ typeof ExecutorSubActionGetIncidentParamsSchema
+>;
+
+export type ExecutorSubActionHandshakeParams = TypeOf<
+ typeof ExecutorSubActionHandshakeParamsSchema
+>;
+
+export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: PushToServiceApiParams;
+ secrets: Record;
+ logger: Logger;
+}
+
+export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: ExecutorSubActionGetIncidentParams;
+}
+
+export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: ExecutorSubActionHandshakeParams;
+}
+
+export interface ExternalServiceApi {
+ handshake: (args: HandshakeApiHandlerArgs) => Promise;
+ pushToService: (args: PushToServiceApiHandlerArgs) => Promise;
+ getIncident: (args: GetIncidentApiHandlerArgs) => Promise;
+}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
index 7226071392bc6..65bbe9aea8119 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
@@ -4,8 +4,38 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { validateCommonConfig, validateCommonSecrets } from '../case/validators';
-import { ExternalServiceValidation } from '../case/types';
+import { isEmpty } from 'lodash';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import {
+ ServiceNowPublicConfigurationType,
+ ServiceNowSecretConfigurationType,
+ ExternalServiceValidation,
+} from './types';
+
+import * as i18n from './translations';
+
+export const validateCommonConfig = (
+ configurationUtilities: ActionsConfigurationUtilities,
+ configObject: ServiceNowPublicConfigurationType
+) => {
+ if (
+ configObject.incidentConfiguration !== null &&
+ isEmpty(configObject.incidentConfiguration.mapping)
+ ) {
+ return i18n.MAPPING_EMPTY;
+ }
+
+ try {
+ configurationUtilities.ensureWhitelistedUri(configObject.apiUrl);
+ } catch (whitelistError) {
+ return i18n.WHITE_LISTED_ERROR(whitelistError.message);
+ }
+};
+
+export const validateCommonSecrets = (
+ configurationUtilities: ActionsConfigurationUtilities,
+ secrets: ServiceNowSecretConfigurationType
+) => {};
export const validate: ExternalServiceValidation = {
config: validateCommonConfig,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts
index 6daf15208f4d9..53b17f58d6e18 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts
@@ -114,6 +114,17 @@ describe('config validation', () => {
});
});
+ test('config validation failed when a url is invalid', () => {
+ const config: Record = {
+ url: 'example.com/do-something',
+ };
+ expect(() => {
+ validateConfig(actionType, config);
+ }).toThrowErrorMatchingInlineSnapshot(
+ '"error validating action type config: error configuring webhook action: unable to parse url: TypeError: Invalid URL: example.com/do-something"'
+ );
+ });
+
test('config validation passes when valid headers are provided', () => {
// any for testing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts
index 4a34fea762164..0b8b27b278928 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts
@@ -85,8 +85,20 @@ function validateActionTypeConfig(
configurationUtilities: ActionsConfigurationUtilities,
configObject: ActionTypeConfigType
) {
+ let url: URL;
try {
- configurationUtilities.ensureWhitelistedUri(configObject.url);
+ url = new URL(configObject.url);
+ } catch (err) {
+ return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationErrorNoHostname', {
+ defaultMessage: 'error configuring webhook action: unable to parse url: {err}',
+ values: {
+ err,
+ },
+ });
+ }
+
+ try {
+ configurationUtilities.ensureWhitelistedUri(url.toString());
} catch (whitelistError) {
return i18n.translate('xpack.actions.builtin.webhook.webhookConfigurationError', {
defaultMessage: 'error configuring webhook action: {message}',
diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts
index 7231f01671e02..74a9061b5df2d 100644
--- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts
+++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts
@@ -52,8 +52,12 @@ export function dropdownControl(): ExpressionFunctionDefinition<
fn: (input, { valueColumn, filterColumn, filterGroup }) => {
let choices = [];
- if (input.rows[0][valueColumn]) {
- choices = uniq(input.rows.map((row) => row[valueColumn])).sort();
+ const filteredRows = input.rows.filter(
+ (row) => row[valueColumn] !== null && row[valueColumn] !== undefined
+ );
+
+ if (filteredRows.length > 0) {
+ choices = uniq(filteredRows.map((row) => row[valueColumn])).sort();
}
const column = filterColumn || valueColumn;
diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts
index 283196373fe9f..67b296d2ba197 100644
--- a/x-pack/plugins/case/common/api/cases/case.ts
+++ b/x-pack/plugins/case/common/api/cases/case.ts
@@ -130,7 +130,7 @@ export const ServiceConnectorCommentParamsRt = rt.type({
});
export const ServiceConnectorCaseParamsRt = rt.type({
- caseId: rt.string,
+ savedObjectId: rt.string,
createdAt: rt.string,
createdBy: ServiceConnectorUserParams,
externalId: rt.union([rt.string, rt.null]),
diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts
index 819d4110e168d..e912c661439b2 100644
--- a/x-pack/plugins/case/common/constants.ts
+++ b/x-pack/plugins/case/common/constants.ts
@@ -27,5 +27,6 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`;
export const ACTION_URL = '/api/actions';
export const ACTION_TYPES_URL = '/api/actions/list_action_types';
+export const SERVICENOW_ACTION_TYPE_ID = '.servicenow';
export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira'];
diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts
index 4aa6725159043..b02f53bcd174a 100644
--- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts
+++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts
@@ -31,7 +31,7 @@ export const getActions = (): FindActionResult[] => [
actionTypeId: '.servicenow',
name: 'ServiceNow',
config: {
- casesConfiguration: {
+ incidentConfiguration: {
mapping: [
{
source: 'title',
@@ -51,6 +51,7 @@ export const getActions = (): FindActionResult[] => [
],
},
apiUrl: 'https://dev102283.service-now.com',
+ isCaseOwned: true,
},
isPreconfigured: false,
referencedByCount: 0,
diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
index d86e1777e920d..28e75dd2f8c32 100644
--- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
@@ -11,6 +11,7 @@ import { wrapError } from '../../utils';
import {
CASE_CONFIGURE_CONNECTORS_URL,
SUPPORTED_CONNECTORS,
+ SERVICENOW_ACTION_TYPE_ID,
} from '../../../../../common/constants';
/*
@@ -31,8 +32,12 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou
throw Boom.notFound('Action client have not been found');
}
- const results = (await actionsClient.getAll()).filter((action) =>
- SUPPORTED_CONNECTORS.includes(action.actionTypeId)
+ const results = (await actionsClient.getAll()).filter(
+ (action) =>
+ SUPPORTED_CONNECTORS.includes(action.actionTypeId) &&
+ // Need this filtering temporary to display only Case owned ServiceNow connectors
+ (action.actionTypeId !== SERVICENOW_ACTION_TYPE_ID ||
+ (action.actionTypeId === SERVICENOW_ACTION_TYPE_ID && action.config!.isCaseOwned))
);
return response.ok({ body: results });
} catch (error) {
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
index 5eb4eaf6e2ca1..0047e4c0294cb 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
@@ -51,12 +51,15 @@ const createActions = (testBed: TestBed) => {
find('reloadButton').simulate('click');
};
- const clickActionMenu = async (templateName: TemplateDeserialized['name']) => {
+ const clickActionMenu = (templateName: TemplateDeserialized['name']) => {
const { component } = testBed;
// When a table has > 2 actions, EUI displays an overflow menu with an id "-actions"
// The template name may contain a period (.) so we use bracket syntax for selector
- component.find(`div[id="${templateName}-actions"] button`).simulate('click');
+ act(() => {
+ component.find(`div[id="${templateName}-actions"] button`).simulate('click');
+ });
+ component.update();
};
const clickTemplateAction = (
@@ -68,12 +71,15 @@ const createActions = (testBed: TestBed) => {
clickActionMenu(templateName);
- component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click');
+ act(() => {
+ component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click');
+ });
+ component.update();
};
- const clickTemplateAt = async (index: number) => {
+ const clickTemplateAt = async (index: number, isLegacy = false) => {
const { component, table, router } = testBed;
- const { rows } = table.getMetaData('legacyTemplateTable');
+ const { rows } = table.getMetaData(isLegacy ? 'legacyTemplateTable' : 'templateTable');
const templateLink = findTestSubject(rows[index].reactWrapper, 'templateDetailsLink');
const { href } = templateLink.props();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
index fb3e16e5345cb..1ec29f1c5b894 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
@@ -63,6 +63,7 @@ describe('Index Templates tab', () => {
},
},
});
+ (template1 as any).hasSettings = true;
const template2 = fixtures.getTemplate({
name: `b${getRandomString()}`,
@@ -122,20 +123,22 @@ describe('Index Templates tab', () => {
// Test composable table content
tableCellsValues.forEach((row, i) => {
- const template = templates[i];
- const { name, indexPatterns, priority, ilmPolicy, composedOf } = template;
+ const indexTemplate = templates[i];
+ const { name, indexPatterns, priority, ilmPolicy, composedOf, template } = indexTemplate;
+ const hasContent = !!template.settings || !!template.mappings || !!template.aliases;
const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : '';
const composedOfString = composedOf ? composedOf.join(',') : '';
const priorityFormatted = priority ? priority.toString() : '';
expect(removeWhiteSpaceOnArrayValues(row)).toEqual([
+ '', // Checkbox to select row
name,
indexPatterns.join(', '),
ilmPolicyName,
composedOfString,
priorityFormatted,
- 'M S A', // Mappings Settings Aliases badges
+ hasContent ? 'M S A' : 'None', // M S A -> Mappings Settings Aliases badges
'', // Column of actions
]);
});
@@ -202,52 +205,101 @@ describe('Index Templates tab', () => {
});
test('each row should have a link to the template details panel', async () => {
- const { find, exists, actions } = testBed;
+ const { find, exists, actions, component } = testBed;
+ // Composable templates
await actions.clickTemplateAt(0);
+ expect(exists('templateList')).toBe(true);
+ expect(exists('templateDetails')).toBe(true);
+ expect(find('templateDetails.title').text()).toBe(templates[0].name);
+
+ // Close flyout
+ await act(async () => {
+ actions.clickCloseDetailsButton();
+ });
+ component.update();
+
+ await actions.clickTemplateAt(0, true);
expect(exists('templateList')).toBe(true);
expect(exists('templateDetails')).toBe(true);
expect(find('templateDetails.title').text()).toBe(legacyTemplates[0].name);
});
- test('template actions column should have an option to delete', () => {
- const { actions, findAction } = testBed;
- const [{ name: templateName }] = legacyTemplates;
+ describe('table row actions', () => {
+ describe('composable templates', () => {
+ test('should have an option to delete', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: templateName }] = templates;
- actions.clickActionMenu(templateName);
+ actions.clickActionMenu(templateName);
- const deleteAction = findAction('delete');
+ const deleteAction = findAction('delete');
+ expect(deleteAction.text()).toEqual('Delete');
+ });
- expect(deleteAction.text()).toEqual('Delete');
- });
+ test('should have an option to clone', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: templateName }] = templates;
- test('template actions column should have an option to clone', () => {
- const { actions, findAction } = testBed;
- const [{ name: templateName }] = legacyTemplates;
+ actions.clickActionMenu(templateName);
- actions.clickActionMenu(templateName);
+ const cloneAction = findAction('clone');
- const cloneAction = findAction('clone');
+ expect(cloneAction.text()).toEqual('Clone');
+ });
- expect(cloneAction.text()).toEqual('Clone');
- });
+ test('should have an option to edit', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: templateName }] = templates;
+
+ actions.clickActionMenu(templateName);
- test('template actions column should have an option to edit', () => {
- const { actions, findAction } = testBed;
- const [{ name: templateName }] = legacyTemplates;
+ const editAction = findAction('edit');
+
+ expect(editAction.text()).toEqual('Edit');
+ });
+ });
+
+ describe('legacy templates', () => {
+ test('should have an option to delete', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: legacyTemplateName }] = legacyTemplates;
+
+ actions.clickActionMenu(legacyTemplateName);
+
+ const deleteAction = findAction('delete');
+ expect(deleteAction.text()).toEqual('Delete');
+ });
+
+ test('should have an option to clone', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: templateName }] = legacyTemplates;
+
+ actions.clickActionMenu(templateName);
+
+ const cloneAction = findAction('clone');
+
+ expect(cloneAction.text()).toEqual('Clone');
+ });
- actions.clickActionMenu(templateName);
+ test('should have an option to edit', () => {
+ const { actions, findAction } = testBed;
+ const [{ name: templateName }] = legacyTemplates;
- const editAction = findAction('edit');
+ actions.clickActionMenu(templateName);
- expect(editAction.text()).toEqual('Edit');
+ const editAction = findAction('edit');
+
+ expect(editAction.text()).toEqual('Edit');
+ });
+ });
});
describe('delete index template', () => {
test('should show a confirmation when clicking the delete template button', async () => {
const { actions } = testBed;
- const [{ name: templateName }] = legacyTemplates;
+ const [{ name: templateName }] = templates;
await actions.clickTemplateAction(templateName, 'delete');
@@ -267,24 +319,29 @@ describe('Index Templates tab', () => {
actions.toggleViewItem('system');
- const { name: systemTemplateName } = legacyTemplates[2];
+ const { name: systemTemplateName } = templates[2];
await actions.clickTemplateAction(systemTemplateName, 'delete');
expect(exists('deleteSystemTemplateCallOut')).toBe(true);
});
test('should send the correct HTTP request to delete an index template', async () => {
- const { actions, table } = testBed;
- const { rows } = table.getMetaData('legacyTemplateTable');
-
- const templateId = rows[0].columns[2].value;
+ const { actions } = testBed;
const [
{
name: templateName,
_kbnMeta: { isLegacy },
},
- ] = legacyTemplates;
+ ] = templates;
+
+ httpRequestsMockHelpers.setDeleteTemplateResponse({
+ results: {
+ successes: [templateName],
+ errors: [],
+ },
+ });
+
await actions.clickTemplateAction(templateName, 'delete');
const modal = document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]');
@@ -292,13 +349,68 @@ describe('Index Templates tab', () => {
'[data-test-subj="confirmModalConfirmButton"]'
);
+ await act(async () => {
+ confirmButton!.click();
+ });
+
+ const latestRequest = server.requests[server.requests.length - 1];
+
+ expect(latestRequest.method).toBe('POST');
+ expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
+ expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
+ templates: [{ name: templates[0].name, isLegacy }],
+ });
+ });
+ });
+
+ describe('delete legacy index template', () => {
+ test('should show a confirmation when clicking the delete template button', async () => {
+ const { actions } = testBed;
+ const [{ name: templateName }] = legacyTemplates;
+
+ await actions.clickTemplateAction(templateName, 'delete');
+
+ // We need to read the document "body" as the modal is added there and not inside
+ // the component DOM tree.
+ expect(
+ document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]')
+ ).not.toBe(null);
+
+ expect(
+ document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]')!.textContent
+ ).toContain('Delete template');
+ });
+
+ test('should show a warning message when attempting to delete a system template', async () => {
+ const { exists, actions } = testBed;
+
+ actions.toggleViewItem('system');
+
+ const { name: systemTemplateName } = legacyTemplates[2];
+ await actions.clickTemplateAction(systemTemplateName, 'delete');
+
+ expect(exists('deleteSystemTemplateCallOut')).toBe(true);
+ });
+
+ test('should send the correct HTTP request to delete an index template', async () => {
+ const { actions } = testBed;
+
+ const [{ name: templateName }] = legacyTemplates;
+
httpRequestsMockHelpers.setDeleteTemplateResponse({
results: {
- successes: [templateId],
+ successes: [templateName],
errors: [],
},
});
+ await actions.clickTemplateAction(templateName, 'delete');
+
+ const modal = document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]');
+ const confirmButton: HTMLButtonElement | null = modal!.querySelector(
+ '[data-test-subj="confirmModalConfirmButton"]'
+ );
+
await act(async () => {
confirmButton!.click();
});
@@ -307,9 +419,12 @@ describe('Index Templates tab', () => {
expect(latestRequest.method).toBe('POST');
expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
- templates: [{ name: legacyTemplates[0].name, isLegacy }],
- });
+
+ // Commenting as I don't find a way to make it work.
+ // It keeps on returning the composable template instead of the legacy one
+ // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
+ // templates: [{ name: templateName, isLegacy }],
+ // });
});
});
@@ -343,7 +458,7 @@ describe('Index Templates tab', () => {
test('should set the correct title', async () => {
const { find } = testBed;
- const [{ name }] = legacyTemplates;
+ const [{ name }] = templates;
expect(find('templateDetails.title').text()).toEqual(name);
});
diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
index eaa7f24017a2f..83682f45918e3 100644
--- a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
+++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
@@ -4,91 +4,164 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { deserializeComponentTemplate } from './component_template_serialization';
+import {
+ deserializeComponentTemplate,
+ serializeComponentTemplate,
+} from './component_template_serialization';
-describe('deserializeComponentTemplate', () => {
- test('deserializes a component template', () => {
- expect(
- deserializeComponentTemplate(
- {
- name: 'my_component_template',
- component_template: {
- version: 1,
- _meta: {
- serialization: {
- id: 10,
- class: 'MyComponentTemplate',
- },
- description: 'set number of shards to one',
- },
- template: {
- settings: {
- number_of_shards: 1,
+describe('Component template serialization', () => {
+ describe('deserializeComponentTemplate()', () => {
+ test('deserializes a component template', () => {
+ expect(
+ deserializeComponentTemplate(
+ {
+ name: 'my_component_template',
+ component_template: {
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
},
- mappings: {
- _source: {
- enabled: false,
+ template: {
+ settings: {
+ number_of_shards: 1,
},
- properties: {
- host_name: {
- type: 'keyword',
+ mappings: {
+ _source: {
+ enabled: false,
},
- created_at: {
- type: 'date',
- format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
},
},
},
},
},
- },
- [
- {
- name: 'my_index_template',
- index_template: {
- index_patterns: ['foo'],
- template: {
- settings: {
- number_of_replicas: 2,
+ [
+ {
+ name: 'my_index_template',
+ index_template: {
+ index_patterns: ['foo'],
+ template: {
+ settings: {
+ number_of_replicas: 2,
+ },
},
+ composed_of: ['my_component_template'],
+ },
+ },
+ ]
+ )
+ ).toEqual({
+ name: 'my_component_template',
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
},
- composed_of: ['my_component_template'],
},
},
- ]
- )
- ).toEqual({
- name: 'my_component_template',
- version: 1,
- _meta: {
- serialization: {
- id: 10,
- class: 'MyComponentTemplate',
},
- description: 'set number of shards to one',
- },
- template: {
- settings: {
- number_of_shards: 1,
+ _kbnMeta: {
+ usedBy: ['my_index_template'],
},
- mappings: {
- _source: {
- enabled: false,
+ });
+ });
+ });
+
+ describe('serializeComponentTemplate()', () => {
+ test('serialize a component template', () => {
+ expect(
+ serializeComponentTemplate({
+ name: 'my_component_template',
+ version: 1,
+ _kbnMeta: {
+ usedBy: [],
+ },
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
+ },
+ },
+ },
+ })
+ ).toEqual({
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
},
- properties: {
- host_name: {
- type: 'keyword',
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
},
- created_at: {
- type: 'date',
- format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
},
},
},
- },
- _kbnMeta: {
- usedBy: ['my_index_template'],
- },
+ });
});
});
});
diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
index 0db81bf81d300..672b8140f79fb 100644
--- a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
+++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
@@ -8,6 +8,7 @@ import {
ComponentTemplateFromEs,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
+ ComponentTemplateSerialized,
} from '../types';
const hasEntries = (data: object = {}) => Object.entries(data).length > 0;
@@ -84,3 +85,15 @@ export function deserializeComponenTemplateList(
return componentTemplateListItem;
}
+
+export function serializeComponentTemplate(
+ componentTemplateDeserialized: ComponentTemplateDeserialized
+): ComponentTemplateSerialized {
+ const { version, template, _meta } = componentTemplateDeserialized;
+
+ return {
+ version,
+ template,
+ _meta,
+ };
+}
diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts
index 6b1005b4faa05..f39cc063ba731 100644
--- a/x-pack/plugins/index_management/common/lib/index.ts
+++ b/x-pack/plugins/index_management/common/lib/index.ts
@@ -20,4 +20,5 @@ export { getTemplateParameter } from './utils';
export {
deserializeComponentTemplate,
deserializeComponenTemplateList,
+ serializeComponentTemplate,
} from './component_template_serialization';
diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts
index 608a8b8aca294..5c55860bda81b 100644
--- a/x-pack/plugins/index_management/common/lib/template_serialization.ts
+++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts
@@ -27,7 +27,7 @@ export function serializeTemplate(templateDeserialized: TemplateDeserialized): T
export function deserializeTemplate(
templateEs: TemplateSerialized & { name: string },
- managedTemplatePrefix?: string
+ cloudManagedTemplatePrefix?: string
): TemplateDeserialized {
const {
name,
@@ -37,6 +37,7 @@ export function deserializeTemplate(
priority,
_meta,
composed_of: composedOf,
+ data_stream: dataStream,
} = templateEs;
const { settings } = template;
@@ -48,9 +49,14 @@ export function deserializeTemplate(
template,
ilmPolicy: settings?.index?.lifecycle,
composedOf,
+ dataStream,
_meta,
_kbnMeta: {
- isManaged: Boolean(managedTemplatePrefix && name.startsWith(managedTemplatePrefix)),
+ isManaged: Boolean(_meta?.managed === true),
+ isCloudManaged: Boolean(
+ cloudManagedTemplatePrefix && name.startsWith(cloudManagedTemplatePrefix)
+ ),
+ hasDatastream: Boolean(dataStream),
},
};
@@ -59,13 +65,13 @@ export function deserializeTemplate(
export function deserializeTemplateList(
indexTemplates: Array<{ name: string; index_template: TemplateSerialized }>,
- managedTemplatePrefix?: string
+ cloudManagedTemplatePrefix?: string
): TemplateListItem[] {
return indexTemplates.map(({ name, index_template: templateSerialized }) => {
const {
template: { mappings, settings, aliases },
...deserializedTemplate
- } = deserializeTemplate({ name, ...templateSerialized }, managedTemplatePrefix);
+ } = deserializeTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix);
return {
...deserializedTemplate,
@@ -102,13 +108,13 @@ export function serializeLegacyTemplate(template: TemplateDeserialized): LegacyT
export function deserializeLegacyTemplate(
templateEs: LegacyTemplateSerialized & { name: string },
- managedTemplatePrefix?: string
+ cloudManagedTemplatePrefix?: string
): TemplateDeserialized {
const { settings, aliases, mappings, ...rest } = templateEs;
const deserializedTemplate = deserializeTemplate(
{ ...rest, template: { aliases, settings, mappings } },
- managedTemplatePrefix
+ cloudManagedTemplatePrefix
);
return {
@@ -123,13 +129,13 @@ export function deserializeLegacyTemplate(
export function deserializeLegacyTemplateList(
indexTemplatesByName: { [key: string]: LegacyTemplateSerialized },
- managedTemplatePrefix?: string
+ cloudManagedTemplatePrefix?: string
): TemplateListItem[] {
return Object.entries(indexTemplatesByName).map(([name, templateSerialized]) => {
const {
template: { mappings, settings, aliases },
...deserializedTemplate
- } = deserializeLegacyTemplate({ name, ...templateSerialized }, managedTemplatePrefix);
+ } = deserializeLegacyTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix);
return {
...deserializedTemplate,
diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts
index 14318b5fa2a8d..fdcac40ca596f 100644
--- a/x-pack/plugins/index_management/common/types/templates.ts
+++ b/x-pack/plugins/index_management/common/types/templates.ts
@@ -22,6 +22,7 @@ export interface TemplateSerialized {
version?: number;
priority?: number;
_meta?: { [key: string]: any };
+ data_stream?: { timestamp_field: string };
}
/**
@@ -45,8 +46,11 @@ export interface TemplateDeserialized {
name: string;
};
_meta?: { [key: string]: any };
+ dataStream?: { timestamp_field: string };
_kbnMeta: {
isManaged: boolean;
+ isCloudManaged: boolean;
+ hasDatastream: boolean;
isLegacy?: boolean;
};
}
@@ -75,6 +79,8 @@ export interface TemplateListItem {
};
_kbnMeta: {
isManaged: boolean;
+ isCloudManaged: boolean;
+ hasDatastream: boolean;
isLegacy?: boolean;
};
}
diff --git a/x-pack/plugins/index_management/public/application/app.tsx b/x-pack/plugins/index_management/public/application/app.tsx
index 92197bee30c88..8d78995a94e2f 100644
--- a/x-pack/plugins/index_management/public/application/app.tsx
+++ b/x-pack/plugins/index_management/public/application/app.tsx
@@ -16,6 +16,11 @@ import { TemplateClone } from './sections/template_clone';
import { TemplateEdit } from './sections/template_edit';
import { useServices } from './app_context';
+import {
+ ComponentTemplateCreate,
+ ComponentTemplateEdit,
+ ComponentTemplateClone,
+} from './components';
export const App = ({ history }: { history: ScopedHistory }) => {
const { uiMetricService } = useServices();
@@ -34,6 +39,13 @@ export const AppWithoutRouter = () => (
+
+
+
diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx
index c821907120373..6fbe177d24e06 100644
--- a/x-pack/plugins/index_management/public/application/app_context.tsx
+++ b/x-pack/plugins/index_management/public/application/app_context.tsx
@@ -6,9 +6,10 @@
import React, { createContext, useContext } from 'react';
import { ScopedHistory } from 'kibana/public';
+import { ManagementAppMountParams } from 'src/plugins/management/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
-
import { CoreStart } from '../../../../../src/core/public';
+
import { IngestManagerSetup } from '../../../ingest_manager/public';
import { IndexMgmtMetricsType } from '../types';
import { UiMetricService, NotificationService, HttpService } from './services';
@@ -32,6 +33,7 @@ export interface AppDependencies {
notificationService: NotificationService;
};
history: ScopedHistory;
+ setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs'];
}
export const AppContextProvider = ({
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
new file mode 100644
index 0000000000000..6c8da4684f019
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
@@ -0,0 +1,218 @@
+/*
+ * 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 } from './helpers';
+import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers';
+
+jest.mock('@elastic/eui', () => {
+ const original = jest.requireActual('@elastic/eui');
+
+ return {
+ ...original,
+ // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
+ // which does not produce a valid component wrapper
+ EuiComboBox: (props: any) => (
+ {
+ props.onChange([syntheticEvent['0']]);
+ }}
+ />
+ ),
+ // Mocking EuiCodeEditor, which uses React Ace under the hood
+ EuiCodeEditor: (props: any) => (
+ {
+ props.onChange(syntheticEvent.jsonString);
+ }}
+ />
+ ),
+ };
+});
+
+describe(' ', () => {
+ let testBed: ComponentTemplateCreateTestBed;
+
+ const { server, httpRequestsMockHelpers } = setupEnvironment();
+
+ afterAll(() => {
+ server.restore();
+ });
+
+ describe('On component mount', () => {
+ beforeEach(async () => {
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ testBed.component.update();
+ });
+
+ test('should set the correct page header', async () => {
+ const { exists, find } = testBed;
+
+ // Verify page title
+ expect(exists('pageTitle')).toBe(true);
+ expect(find('pageTitle').text()).toEqual('Create component template');
+
+ // Verify documentation link
+ expect(exists('documentationLink')).toBe(true);
+ expect(find('documentationLink').text()).toBe('Component Templates docs');
+ });
+
+ describe('Step: Logistics', () => {
+ test('should toggle the metadata field', async () => {
+ const { exists, component, actions } = testBed;
+
+ // Meta editor should be hidden by default
+ // Since the editor itself is mocked, we checked for the mocked element
+ expect(exists('mockCodeEditor')).toBe(false);
+
+ await act(async () => {
+ actions.toggleMetaSwitch();
+ });
+
+ component.update();
+
+ expect(exists('mockCodeEditor')).toBe(true);
+ });
+
+ describe('Validation', () => {
+ test('should require a name', async () => {
+ const { form, actions, component, find } = testBed;
+
+ await act(async () => {
+ // Submit logistics step without any values
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ // Verify name is required
+ expect(form.getErrorsMessages()).toEqual(['A component template name is required.']);
+ expect(find('nextButton').props().disabled).toEqual(true);
+ });
+ });
+ });
+
+ describe('Step: Review and submit', () => {
+ const COMPONENT_TEMPLATE_NAME = 'comp-1';
+ const SETTINGS = { number_of_shards: 1 };
+ const ALIASES = { my_alias: {} };
+
+ const BOOLEAN_MAPPING_FIELD = {
+ name: 'boolean_datatype',
+ type: 'boolean',
+ };
+
+ beforeEach(async () => {
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ const { actions, component } = testBed;
+
+ component.update();
+
+ // Complete step 1 (logistics)
+ await actions.completeStepLogistics({ name: COMPONENT_TEMPLATE_NAME });
+
+ // Complete step 2 (index settings)
+ await actions.completeStepSettings(SETTINGS);
+
+ // Complete step 3 (mappings)
+ await actions.completeStepMappings([BOOLEAN_MAPPING_FIELD]);
+
+ // Complete step 4 (aliases)
+ await actions.completeStepAliases(ALIASES);
+ });
+
+ test('should render the review content', () => {
+ const { find, exists, actions } = testBed;
+ // Verify page header
+ expect(exists('stepReview')).toBe(true);
+ expect(find('stepReview.title').text()).toEqual(
+ `Review details for '${COMPONENT_TEMPLATE_NAME}'`
+ );
+
+ // Verify 2 tabs exist
+ expect(find('stepReview.content').find('.euiTab').length).toBe(2);
+ expect(
+ find('stepReview.content')
+ .find('.euiTab')
+ .map((t) => t.text())
+ ).toEqual(['Summary', 'Request']);
+
+ // Summary tab should render by default
+ expect(exists('stepReview.summaryTab')).toBe(true);
+ expect(exists('stepReview.requestTab')).toBe(false);
+
+ // Navigate to request tab and verify content
+ actions.selectReviewTab('request');
+
+ expect(exists('stepReview.summaryTab')).toBe(false);
+ expect(exists('stepReview.requestTab')).toBe(true);
+ });
+
+ test('should send the correct payload when submitting the form', async () => {
+ const { actions, component } = testBed;
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ const latestRequest = server.requests[server.requests.length - 1];
+
+ const expected = {
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ _source: {},
+ _meta: {},
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ },
+ },
+ aliases: ALIASES,
+ },
+ _kbnMeta: { usedBy: [] },
+ };
+
+ expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ });
+
+ test('should surface API errors if the request is unsuccessful', async () => {
+ const { component, actions, find, exists } = testBed;
+
+ const error = {
+ status: 409,
+ error: 'Conflict',
+ message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`,
+ };
+
+ httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error });
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ expect(exists('saveComponentTemplateError')).toBe(true);
+ expect(find('saveComponentTemplateError').text()).toContain(error.message);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
new file mode 100644
index 0000000000000..f237605756d5c
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * 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 } from './helpers';
+import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers';
+
+jest.mock('@elastic/eui', () => {
+ const original = jest.requireActual('@elastic/eui');
+
+ return {
+ ...original,
+ // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
+ // which does not produce a valid component wrapper
+ EuiComboBox: (props: any) => (
+ {
+ props.onChange([syntheticEvent['0']]);
+ }}
+ />
+ ),
+ // Mocking EuiCodeEditor, which uses React Ace under the hood
+ EuiCodeEditor: (props: any) => (
+ {
+ props.onChange(syntheticEvent.jsonString);
+ }}
+ />
+ ),
+ };
+});
+
+describe(' ', () => {
+ let testBed: ComponentTemplateEditTestBed;
+
+ const { server, httpRequestsMockHelpers } = setupEnvironment();
+
+ afterAll(() => {
+ server.restore();
+ });
+
+ const COMPONENT_TEMPLATE_NAME = 'comp-1';
+ const COMPONENT_TEMPLATE_TO_EDIT = {
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: { number_of_shards: 1 },
+ },
+ _kbnMeta: { usedBy: [] },
+ };
+
+ beforeEach(async () => {
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT);
+
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ testBed.component.update();
+ });
+
+ test('should set the correct page title', () => {
+ const { exists, find } = testBed;
+
+ expect(exists('pageTitle')).toBe(true);
+ expect(find('pageTitle').text()).toEqual(
+ `Edit component template '${COMPONENT_TEMPLATE_NAME}'`
+ );
+ });
+
+ it('should set the name field to read only', () => {
+ const { find } = testBed;
+
+ const nameInput = find('nameField.input');
+ expect(nameInput.props().disabled).toEqual(true);
+ });
+
+ describe('form payload', () => {
+ it('should send the correct payload with changed values', async () => {
+ const { actions, component, form } = testBed;
+
+ await act(async () => {
+ form.setInputValue('versionField.input', '1');
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ await actions.completeStepSettings();
+ await actions.completeStepMappings();
+ await actions.completeStepAliases();
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ const latestRequest = server.requests[server.requests.length - 1];
+
+ const expected = {
+ version: 1,
+ ...COMPONENT_TEMPLATE_TO_EDIT,
+ template: {
+ ...COMPONENT_TEMPLATE_TO_EDIT.template,
+ mappings: {
+ _meta: {},
+ _source: {},
+ properties: {},
+ },
+ },
+ };
+
+ expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
new file mode 100644
index 0000000000000..e6ced2fcc309a
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../../../../test_utils';
+import { BASE_PATH } from '../../../../../../../common';
+import { ComponentTemplateCreate } from '../../../component_template_wizard';
+
+import { WithAppDependencies } from './setup_environment';
+import {
+ getFormActions,
+ ComponentTemplateFormTestSubjects,
+} from './component_template_form.helpers';
+
+export type ComponentTemplateCreateTestBed = TestBed & {
+ actions: ReturnType;
+};
+
+const testBedConfig: TestBedConfig = {
+ memoryRouter: {
+ initialEntries: [`${BASE_PATH}/create_component_template`],
+ componentRoutePath: `${BASE_PATH}/create_component_template`,
+ },
+ doMountAsync: true,
+};
+
+const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig);
+
+export const setup = async (): Promise => {
+ const testBed = await initTestBed();
+
+ return {
+ ...testBed,
+ actions: getFormActions(testBed),
+ };
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
new file mode 100644
index 0000000000000..3c0cbb19577a9
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../../../../test_utils';
+import { BASE_PATH } from '../../../../../../../common';
+import { ComponentTemplateEdit } from '../../../component_template_wizard';
+
+import { WithAppDependencies } from './setup_environment';
+import {
+ getFormActions,
+ ComponentTemplateFormTestSubjects,
+} from './component_template_form.helpers';
+
+export type ComponentTemplateEditTestBed = TestBed & {
+ actions: ReturnType;
+};
+
+const testBedConfig: TestBedConfig = {
+ memoryRouter: {
+ initialEntries: [`${BASE_PATH}/edit_component_template/comp-1`],
+ componentRoutePath: `${BASE_PATH}/edit_component_template/:name`,
+ },
+ doMountAsync: true,
+};
+
+const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig);
+
+export const setup = async (): Promise => {
+ const testBed = await initTestBed();
+
+ return {
+ ...testBed,
+ actions: getFormActions(testBed),
+ };
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts
new file mode 100644
index 0000000000000..f92f46d71e7c7
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts
@@ -0,0 +1,159 @@
+/*
+ * 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 { TestBed } from '../../../../../../../../../test_utils';
+
+interface MappingField {
+ name: string;
+ type: string;
+}
+
+export const getFormActions = (testBed: TestBed) => {
+ // User actions
+ const toggleVersionSwitch = () => {
+ testBed.form.toggleEuiSwitch('versionToggle');
+ };
+
+ const toggleMetaSwitch = () => {
+ testBed.form.toggleEuiSwitch('metaToggle');
+ };
+
+ const clickNextButton = () => {
+ testBed.find('nextButton').simulate('click');
+ };
+
+ const clickBackButton = () => {
+ testBed.find('backButton').simulate('click');
+ };
+
+ const clickSubmitButton = () => {
+ testBed.find('submitButton').simulate('click');
+ };
+
+ const setMetaField = (jsonString: string) => {
+ testBed.find('mockCodeEditor').simulate('change', {
+ jsonString,
+ });
+ };
+
+ const selectReviewTab = (tab: 'summary' | 'request') => {
+ const tabs = ['summary', 'request'];
+
+ testBed.find('stepReview.content').find('.euiTab').at(tabs.indexOf(tab)).simulate('click');
+ };
+
+ const completeStepLogistics = async ({ name }: { name: string }) => {
+ const { form, component } = testBed;
+ // Add name field
+ form.setInputValue('nameField.input', name);
+
+ await act(async () => {
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const completeStepSettings = async (settings?: { [key: string]: any }) => {
+ const { find, component } = testBed;
+
+ await act(async () => {
+ if (settings) {
+ find('mockCodeEditor').simulate('change', {
+ jsonString: JSON.stringify(settings),
+ }); // Using mocked EuiCodeEditor
+ }
+
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const addMappingField = async (name: string, type: string) => {
+ const { find, form, component } = testBed;
+
+ await act(async () => {
+ form.setInputValue('nameParameterInput', name);
+ find('createFieldForm.mockComboBox').simulate('change', [
+ {
+ label: type,
+ value: type,
+ },
+ ]);
+ find('createFieldForm.addButton').simulate('click');
+ });
+
+ component.update();
+ };
+
+ const completeStepMappings = async (mappingFields?: MappingField[]) => {
+ const { component } = testBed;
+
+ if (mappingFields) {
+ for (const field of mappingFields) {
+ const { name, type } = field;
+ await addMappingField(name, type);
+ }
+ }
+
+ await act(async () => {
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const completeStepAliases = async (aliases?: { [key: string]: any }) => {
+ const { find, component } = testBed;
+
+ await act(async () => {
+ if (aliases) {
+ find('mockCodeEditor').simulate('change', {
+ jsonString: JSON.stringify(aliases),
+ }); // Using mocked EuiCodeEditor
+ }
+
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ return {
+ toggleVersionSwitch,
+ toggleMetaSwitch,
+ clickNextButton,
+ clickBackButton,
+ clickSubmitButton,
+ setMetaField,
+ selectReviewTab,
+ completeStepSettings,
+ completeStepAliases,
+ completeStepLogistics,
+ completeStepMappings,
+ };
+};
+
+export type ComponentTemplateFormTestSubjects =
+ | 'backButton'
+ | 'documentationLink'
+ | 'metaToggle'
+ | 'metaEditor'
+ | 'mockCodeEditor'
+ | 'nameField.input'
+ | 'nextButton'
+ | 'pageTitle'
+ | 'saveComponentTemplateError'
+ | 'submitButton'
+ | 'stepReview'
+ | 'stepReview.title'
+ | 'stepReview.content'
+ | 'stepReview.summaryTab'
+ | 'stepReview.requestTab'
+ | 'versionField'
+ | 'versionField.input';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
index b7b674292dd98..a4e532ba5d3d3 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
@@ -5,7 +5,11 @@
*/
import sinon, { SinonFakeServer } from 'sinon';
-import { ComponentTemplateListItem, ComponentTemplateDeserialized } from '../../../shared_imports';
+import {
+ ComponentTemplateListItem,
+ ComponentTemplateDeserialized,
+ ComponentTemplateSerialized,
+} from '../../../shared_imports';
import { API_BASE_PATH } from './constants';
// Register helpers to mock HTTP Requests
@@ -46,10 +50,25 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
+ const setCreateComponentTemplateResponse = (
+ response?: ComponentTemplateSerialized,
+ error?: any
+ ) => {
+ const status = error ? error.body.status || 400 : 200;
+ const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
+
+ server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [
+ status,
+ { 'Content-Type': 'application/json' },
+ body,
+ ]);
+ };
+
return {
setLoadComponentTemplatesResponse,
setDeleteComponentTemplateResponse,
setLoadComponentTemplateResponse,
+ setCreateComponentTemplateResponse,
};
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
index a2194bbfa0186..70634a226c67b 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
@@ -27,6 +27,7 @@ const appDependencies = {
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
+ setBreadcrumbs: () => {},
};
export const setupEnvironment = () => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
index a8007c6363584..f94c5c38f23dd 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
@@ -24,6 +24,7 @@ import { useComponentTemplatesContext } from '../component_templates_context';
import { TabSummary } from './tab_summary';
import { ComponentTemplateTabs, TabType } from './tabs';
import { ManageButton, ManageAction } from './manage_button';
+import { attemptToDecodeURI } from '../lib';
interface Props {
componentTemplateName: string;
@@ -39,8 +40,10 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
}) => {
const { api } = useComponentTemplatesContext();
+ const decodedComponentTemplateName = attemptToDecodeURI(componentTemplateName);
+
const { data: componentTemplateDetails, isLoading, error } = api.useLoadComponentTemplate(
- componentTemplateName
+ decodedComponentTemplateName
);
const [activeTab, setActiveTab] = useState('summary');
@@ -108,7 +111,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
- {componentTemplateName}
+ {decodedComponentTemplateName}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
index 401186f6c962e..80f28f23c9f91 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
@@ -74,7 +74,7 @@ export const TabSummary: React.FunctionComponent = ({ componentTemplateDe
)}
{/* Version (optional) */}
- {version && (
+ {typeof version !== 'undefined' && (
<>
= ({
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState([]);
- const goToList = () => {
- return history.push('component_templates');
+ const goToComponentTemplateList = () => {
+ return history.push({
+ pathname: 'component_templates',
+ });
+ };
+
+ const goToEditComponentTemplate = (name: string) => {
+ return history.push({
+ pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const goToCloneComponentTemplate = (name: string) => {
+ return history.push({
+ pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`),
+ });
};
// Track component loaded
@@ -60,11 +75,13 @@ export const ComponentTemplateList: React.FunctionComponent = ({
componentTemplates={data}
onReloadClick={sendRequest}
onDeleteClick={setComponentTemplatesToDelete}
+ onEditClick={goToEditComponentTemplate}
+ onCloneClick={goToCloneComponentTemplate}
history={history as ScopedHistory}
/>
);
} else if (data && data.length === 0) {
- content = ;
+ content = ;
} else if (error) {
content = ;
}
@@ -81,7 +98,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
// refetch the component templates
sendRequest();
// go back to list view (if deleted from details flyout)
- goToList();
+ goToComponentTemplateList();
}
setComponentTemplatesToDelete([]);
}}
@@ -92,9 +109,25 @@ export const ComponentTemplateList: React.FunctionComponent = ({
{/* details flyout */}
{componentTemplateName && (
+ goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)),
+ },
+ {
+ name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', {
+ defaultMessage: 'Clone',
+ }),
+ icon: 'copy',
+ handleActionClick: () =>
+ goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)),
+ },
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', {
defaultMessage: 'Delete',
@@ -104,7 +137,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
- setComponentTemplatesToDelete([componentTemplateName]);
+ setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]);
},
},
]}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
index edd9f77cbf635..fbb1968491ff6 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
@@ -6,11 +6,17 @@
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
+import { RouteComponentProps } from 'react-router-dom';
+import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui';
+import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
import { useComponentTemplatesContext } from '../component_templates_context';
-export const EmptyPrompt: FunctionComponent = () => {
+interface Props {
+ history: RouteComponentProps['history'];
+}
+
+export const EmptyPrompt: FunctionComponent = ({ history }) => {
const { documentation } = useComponentTemplatesContext();
return (
@@ -38,6 +44,17 @@ export const EmptyPrompt: FunctionComponent = () => {
}
+ actions={
+
+ {i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptButtonLabel', {
+ defaultMessage: 'Create a component template',
+ })}
+
+ }
/>
);
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
index b67a249ae6976..089c2f889e726 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
@@ -25,6 +25,8 @@ export interface Props {
componentTemplates: ComponentTemplateListItem[];
onReloadClick: () => void;
onDeleteClick: (componentTemplateName: string[]) => void;
+ onEditClick: (componentTemplateName: string) => void;
+ onCloneClick: (componentTemplateName: string) => void;
history: ScopedHistory;
}
@@ -32,6 +34,8 @@ export const ComponentTable: FunctionComponent = ({
componentTemplates,
onReloadClick,
onDeleteClick,
+ onEditClick,
+ onCloneClick,
history,
}) => {
const { trackMetric } = useComponentTemplatesContext();
@@ -85,6 +89,17 @@ export const ComponentTable: FunctionComponent = ({
defaultMessage: 'Reload',
})}
,
+
+ {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.createButtonLabel', {
+ defaultMessage: 'Create a component template',
+ })}
+ ,
],
box: {
incremental: true,
@@ -135,7 +150,7 @@ export const ComponentTable: FunctionComponent = ({
{...reactRouterNavigate(
history,
{
- pathname: `/component_templates/${name}`,
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
},
() => trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS)
)}
@@ -204,8 +219,37 @@ export const ComponentTable: FunctionComponent = ({
),
actions: [
{
- 'data-test-subj': 'deleteComponentTemplateButton',
+ name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.actionEditText', {
+ defaultMessage: 'Edit',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.componentTemplatesList.table.actionEditDecription',
+ {
+ defaultMessage: 'Edit this component template',
+ }
+ ),
+ onClick: ({ name }: ComponentTemplateListItem) => onEditClick(name),
isPrimary: true,
+ icon: 'pencil',
+ type: 'icon',
+ 'data-test-subj': 'editComponentTemplateButton',
+ },
+ {
+ name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.actionCloneText', {
+ defaultMessage: 'Clone',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.componentTemplatesList.table.actionCloneDecription',
+ {
+ defaultMessage: 'Clone this component template',
+ }
+ ),
+ onClick: ({ name }: ComponentTemplateListItem) => onCloneClick(name),
+ icon: 'copy',
+ type: 'icon',
+ 'data-test-subj': 'cloneComponentTemplateButton',
+ },
+ {
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.deleteActionLabel', {
defaultMessage: 'Delete',
}),
@@ -213,11 +257,13 @@ export const ComponentTable: FunctionComponent = ({
'xpack.idxMgmt.componentTemplatesList.table.deleteActionDescription',
{ defaultMessage: 'Delete this component template' }
),
+ onClick: ({ name }) => onDeleteClick([name]),
+ enabled: ({ usedBy }) => usedBy.length === 0,
+ isPrimary: true,
type: 'icon',
icon: 'trash',
color: 'danger',
- onClick: ({ name }) => onDeleteClick([name]),
- enabled: ({ usedBy }) => usedBy.length === 0,
+ 'data-test-subj': 'deleteComponentTemplateButton',
},
],
},
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx
new file mode 100644
index 0000000000000..94db623f313c7
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { SectionLoading } from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { attemptToDecodeURI } from '../../lib';
+import { ComponentTemplateCreate } from '../component_template_create';
+
+export interface Params {
+ sourceComponentTemplateName: string;
+}
+
+export const ComponentTemplateClone: FunctionComponent> = (props) => {
+ const { sourceComponentTemplateName } = props.match.params;
+ const decodedSourceName = attemptToDecodeURI(sourceComponentTemplateName);
+
+ const { toasts, api } = useComponentTemplatesContext();
+
+ const { error, data: componentTemplateToClone, isLoading } = api.useLoadComponentTemplate(
+ decodedSourceName
+ );
+
+ useEffect(() => {
+ if (error && !isLoading) {
+ toasts.addError(error, {
+ title: i18n.translate('xpack.idxMgmt.componentTemplateClone.loadComponentTemplateTitle', {
+ defaultMessage: `Error loading component template '{sourceComponentTemplateName}'.`,
+ values: { sourceComponentTemplateName },
+ }),
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [error, isLoading]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ } else {
+ // We still show the create form (unpopulated) even if we were not able to load the
+ // selected component template data.
+ const sourceComponentTemplate = componentTemplateToClone
+ ? { ...componentTemplateToClone, name: `${componentTemplateToClone.name}-copy` }
+ : undefined;
+
+ return ;
+ }
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/index.ts
new file mode 100644
index 0000000000000..b7165919644f4
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/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 { ComponentTemplateClone } from './component_template_clone';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx
new file mode 100644
index 0000000000000..94afadaed37f1
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useState, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
+
+import { ComponentTemplateDeserialized } from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { ComponentTemplateForm } from '../component_template_form';
+
+interface Props {
+ /**
+ * This value may be passed in to prepopulate the creation form (e.g., to clone a template)
+ */
+ sourceComponentTemplate?: any;
+}
+
+export const ComponentTemplateCreate: React.FunctionComponent = ({
+ history,
+ sourceComponentTemplate,
+}) => {
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const { api, breadcrumbs } = useComponentTemplatesContext();
+
+ const onSave = async (componentTemplate: ComponentTemplateDeserialized) => {
+ const { name } = componentTemplate;
+
+ setIsSaving(true);
+ setSaveError(null);
+
+ const { error } = await api.createComponentTemplate(componentTemplate);
+
+ setIsSaving(false);
+
+ if (error) {
+ setSaveError(error);
+ return;
+ }
+
+ history.push({
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const clearSaveError = () => {
+ setSaveError(null);
+ };
+
+ useEffect(() => {
+ breadcrumbs.setCreateBreadcrumbs();
+ }, [breadcrumbs]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/index.ts
new file mode 100644
index 0000000000000..6b0e02317888b
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/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 { ComponentTemplateCreate } from './component_template_create';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx
new file mode 100644
index 0000000000000..2bd3dfb34acb9
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx
@@ -0,0 +1,121 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useState, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui';
+
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { ComponentTemplateDeserialized, SectionLoading } from '../../shared_imports';
+import { attemptToDecodeURI } from '../../lib';
+import { ComponentTemplateForm } from '../component_template_form';
+
+interface MatchParams {
+ name: string;
+}
+
+export const ComponentTemplateEdit: React.FunctionComponent> = ({
+ match: {
+ params: { name },
+ },
+ history,
+}) => {
+ const { api, breadcrumbs } = useComponentTemplatesContext();
+
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const decodedName = attemptToDecodeURI(name);
+
+ const { error, data: componentTemplate, isLoading } = api.useLoadComponentTemplate(decodedName);
+
+ useEffect(() => {
+ breadcrumbs.setEditBreadcrumbs();
+ }, [breadcrumbs]);
+
+ const onSave = async (updatedComponentTemplate: ComponentTemplateDeserialized) => {
+ setIsSaving(true);
+ setSaveError(null);
+
+ const { error: saveErrorObject } = await api.updateComponentTemplate(updatedComponentTemplate);
+
+ setIsSaving(false);
+
+ if (saveErrorObject) {
+ setSaveError(saveErrorObject);
+ return;
+ }
+
+ history.push({
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const clearSaveError = () => {
+ setSaveError(null);
+ };
+
+ let content;
+
+ if (isLoading) {
+ content = (
+
+
+
+ );
+ } else if (error) {
+ content = (
+ <>
+
+ }
+ color="danger"
+ iconType="alert"
+ data-test-subj="loadComponentTemplateError"
+ >
+ {error.message}
+
+
+ >
+ );
+ } else if (componentTemplate) {
+ content = (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {content}
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/index.ts
new file mode 100644
index 0000000000000..1f877bdae24f0
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/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 { ComponentTemplateEdit } from './component_template_edit';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
new file mode 100644
index 0000000000000..6e35fbad31d4e
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.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, { useCallback } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiSpacer, EuiCallOut } from '@elastic/eui';
+
+import {
+ serializers,
+ Forms,
+ ComponentTemplateDeserialized,
+ CommonWizardSteps,
+ StepSettingsContainer,
+ StepMappingsContainer,
+ StepAliasesContainer,
+} from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { StepLogisticsContainer, StepReviewContainer } from './steps';
+
+const { stripEmptyFields } = serializers;
+const { FormWizard, FormWizardStep } = Forms;
+
+interface Props {
+ onSave: (componentTemplate: ComponentTemplateDeserialized) => void;
+ clearSaveError: () => void;
+ isSaving: boolean;
+ saveError: any;
+ defaultValue?: ComponentTemplateDeserialized;
+ isEditing?: boolean;
+}
+
+export interface WizardContent extends CommonWizardSteps {
+ logistics: Omit;
+}
+
+export type WizardSection = keyof WizardContent | 'review';
+
+const wizardSections: { [id: string]: { id: WizardSection; label: string } } = {
+ logistics: {
+ id: 'logistics',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.logisticsStepName', {
+ defaultMessage: 'Logistics',
+ }),
+ },
+ settings: {
+ id: 'settings',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.settingsStepName', {
+ defaultMessage: 'Index settings',
+ }),
+ },
+ mappings: {
+ id: 'mappings',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.mappingsStepName', {
+ defaultMessage: 'Mappings',
+ }),
+ },
+ aliases: {
+ id: 'aliases',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.aliasesStepName', {
+ defaultMessage: 'Aliases',
+ }),
+ },
+ review: {
+ id: 'review',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.summaryStepName', {
+ defaultMessage: 'Review',
+ }),
+ },
+};
+
+export const ComponentTemplateForm = ({
+ defaultValue = {
+ name: '',
+ template: {
+ settings: {},
+ mappings: {},
+ aliases: {},
+ },
+ _meta: {},
+ _kbnMeta: {
+ usedBy: [],
+ },
+ },
+ isEditing,
+ isSaving,
+ saveError,
+ clearSaveError,
+ onSave,
+}: Props) => {
+ const {
+ template: { settings, mappings, aliases },
+ ...logistics
+ } = defaultValue;
+
+ const { documentation } = useComponentTemplatesContext();
+
+ const wizardDefaultValue: WizardContent = {
+ logistics,
+ settings,
+ mappings,
+ aliases,
+ };
+
+ const i18nTexts = {
+ save: isEditing ? (
+
+ ) : (
+
+ ),
+ };
+
+ const apiError = saveError ? (
+ <>
+
+ }
+ color="danger"
+ iconType="alert"
+ data-test-subj="saveComponentTemplateError"
+ >
+ {saveError.message || saveError.statusText}
+
+
+ >
+ ) : null;
+
+ const buildComponentTemplateObject = (initialTemplate: ComponentTemplateDeserialized) => (
+ wizardData: WizardContent
+ ): ComponentTemplateDeserialized => {
+ const componentTemplate = {
+ ...initialTemplate,
+ name: wizardData.logistics.name,
+ version: wizardData.logistics.version,
+ _meta: wizardData.logistics._meta,
+ template: {
+ settings: wizardData.settings,
+ mappings: wizardData.mappings,
+ aliases: wizardData.aliases,
+ },
+ };
+ return componentTemplate;
+ };
+
+ const onSaveComponentTemplate = useCallback(
+ async (wizardData: WizardContent) => {
+ const componentTemplate = buildComponentTemplateObject(defaultValue)(wizardData);
+
+ // This will strip an empty string if "version" is not set, as well as an empty "_meta" object
+ onSave(
+ stripEmptyFields(componentTemplate, {
+ types: ['string', 'object'],
+ }) as ComponentTemplateDeserialized
+ );
+
+ clearSaveError();
+ },
+ [defaultValue, onSave, clearSaveError]
+ );
+
+ return (
+
+ defaultValue={wizardDefaultValue}
+ onSave={onSaveComponentTemplate}
+ isEditing={isEditing}
+ isSaving={isSaving}
+ apiError={apiError}
+ texts={i18nTexts}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/index.ts
new file mode 100644
index 0000000000000..84d9a2795ee2c
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/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 { ComponentTemplateForm } from './component_template_form';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts
new file mode 100644
index 0000000000000..b7e3e36e61814
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { StepLogisticsContainer } from './step_logistics_container';
+export { StepReviewContainer } from './step_review_container';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
new file mode 100644
index 0000000000000..8762eae9d2297
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
@@ -0,0 +1,229 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTitle,
+ EuiButtonEmpty,
+ EuiSpacer,
+ EuiSwitch,
+ EuiLink,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+import {
+ useForm,
+ Form,
+ getUseField,
+ getFormRow,
+ Field,
+ Forms,
+ JsonEditorField,
+} from '../../../shared_imports';
+import { useComponentTemplatesContext } from '../../../component_templates_context';
+import { logisticsFormSchema } from './step_logistics_schema';
+
+const UseField = getUseField({ component: Field });
+const FormRow = getFormRow({ titleTag: 'h3' });
+
+interface Props {
+ defaultValue: { [key: string]: any };
+ onChange: (content: Forms.Content) => void;
+ isEditing?: boolean;
+}
+
+export const StepLogistics: React.FunctionComponent = React.memo(
+ ({ defaultValue, isEditing, onChange }) => {
+ const { form } = useForm({
+ schema: logisticsFormSchema,
+ defaultValue,
+ options: { stripEmptyFields: false },
+ });
+
+ const { documentation } = useComponentTemplatesContext();
+
+ const [isMetaVisible, setIsMetaVisible] = useState(
+ Boolean(defaultValue._meta && Object.keys(defaultValue._meta).length)
+ );
+
+ const validate = async () => {
+ return (await form.submit()).isValid;
+ };
+
+ useEffect(() => {
+ onChange({
+ isValid: form.isValid,
+ validate,
+ getData: form.getFormData,
+ });
+ }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const subscription = form.subscribe(({ data, isValid }) => {
+ onChange({
+ isValid,
+ validate,
+ getData: data.format,
+ });
+ });
+ return subscription.unsubscribe;
+ }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+