From 57f74012b16c933f3a81a93e58d2e0aa96c1fb11 Mon Sep 17 00:00:00 2001
From: Joe Reuter
Date: Wed, 4 Nov 2020 09:49:52 +0100
Subject: [PATCH 01/69] [Lens] Add functional test for wrapping labels (#82453)
* add functional test for wrapping labels
* fix unused import
---
.../test/functional/apps/lens/smokescreen.ts | 21 ++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts
index 0ddafe581c21d..58ae4dc25885a 100644
--- a/x-pack/test/functional/apps/lens/smokescreen.ts
+++ b/x-pack/test/functional/apps/lens/smokescreen.ts
@@ -8,7 +8,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
- const PageObjects = getPageObjects(['visualize', 'lens', 'common']);
+ const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']);
const find = getService('find');
const listingTable = getService('listingTable');
const testSubjects = getService('testSubjects');
@@ -169,6 +169,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql(
'Test of label'
);
+ await PageObjects.lens.closeDimensionEditor();
+ });
+
+ it('should be able to add very long labels and still be able to remove a dimension', async () => {
+ await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger');
+ const longLabel =
+ 'Veryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryvery long label wrapping multiple lines';
+ await PageObjects.lens.editDimensionLabel(longLabel);
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.lens.closeDimensionEditor();
+
+ expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql(
+ longLabel
+ );
+ expect(
+ await testSubjects.isDisplayed('lnsXY_yDimensionPanel > indexPattern-dimension-remove')
+ ).to.equal(true);
+ await PageObjects.lens.removeDimension('lnsXY_yDimensionPanel');
+ await testSubjects.missingOrFail('lnsXY_yDimensionPanel > lns-dimensionTrigger');
});
it('should transition from a multi-layer stacked bar to donut chart using suggestions', async () => {
From 7abb1e3033404d8f69ba78cf746e0cd45e3215c2 Mon Sep 17 00:00:00 2001
From: Christos Nasikas
Date: Wed, 4 Nov 2020 12:07:17 +0200
Subject: [PATCH 02/69] [Security Solution][Case] Case action type (#80870)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Init connector
* Add test
* Improve comment type
* Add integration tests
* Fix i18n
* Improve tests
* Show unknown when username is null
* Improve comment type
* Pass connector to case client
* Improve type after PR #82125
* Add comment migration test
* Fix integration tests
* Fix reporter on table
* Create case connector ui
* Add connector to README
* Improve casting on executor
* Translate name
* Improve test
* Create comment type enum
* Fix type
* Fix i18n
* Move README to cases
* Filter out case connector from alerting
Co-authored-by: Mike Côté
Co-authored-by: Mike Côté
---
x-pack/.i18nrc.json | 1 +
x-pack/plugins/actions/README.md | 2 +-
x-pack/plugins/case/README.md | 88 ++
.../plugins/case/common/api/cases/comment.ts | 8 +-
.../case/server/client/cases/create.test.ts | 54 +-
.../case/server/client/cases/update.test.ts | 58 +-
.../case/server/client/comments/add.test.ts | 36 +-
.../case/server/connectors/case/index.test.ts | 891 ++++++++++++++++++
.../case/server/connectors/case/index.ts | 91 ++
.../case/server/connectors/case/schema.ts | 109 +++
.../server/connectors/case/translations.ts | 11 +
.../case/server/connectors/case/types.ts | 42 +
.../case/server/connectors/case/validators.ts | 13 +
.../plugins/case/server/connectors/index.ts | 56 ++
x-pack/plugins/case/server/plugin.ts | 11 +
.../api/__fixtures__/mock_saved_objects.ts | 4 +
.../api/cases/comments/post_comment.test.ts | 5 +
.../case/server/routes/api/utils.test.ts | 8 +-
.../plugins/case/server/routes/api/utils.ts | 7 +-
.../server/saved_object_types/comments.ts | 5 +
.../server/saved_object_types/migrations.ts | 26 +-
.../components/add_comment/index.test.tsx | 4 +-
.../cases/components/add_comment/index.tsx | 5 +-
.../cases/components/all_cases/columns.tsx | 4 +-
.../components/case_view/translations.ts | 4 -
.../components/user_action_tree/helpers.tsx | 4 +-
.../components/user_action_tree/index.tsx | 18 +-
.../user_action_avatar.test.tsx | 14 +-
.../user_action_tree/user_action_avatar.tsx | 16 +-
.../user_action_tree/user_action_username.tsx | 11 +-
.../user_action_username_with_avatar.tsx | 7 +-
.../public/cases/containers/api.test.tsx | 3 +-
.../public/cases/containers/mock.ts | 4 +-
.../public/cases/containers/types.ts | 9 +-
.../containers/use_post_comment.test.tsx | 3 +
.../public/cases/translations.ts | 4 +
.../common/lib/connectors/case/index.ts | 22 +
.../lib/connectors/case/translations.ts | 21 +
.../public/common/lib/connectors/index.ts | 7 +
.../security_solution/public/plugin.tsx | 3 +
.../action_connector_form/action_form.tsx | 7 +-
.../public/common/constants/index.ts | 2 +
.../tests/cases/comments/delete_comment.ts | 8 +-
.../tests/cases/comments/find_comments.ts | 25 +-
.../basic/tests/cases/comments/get_comment.ts | 6 +-
.../basic/tests/cases/comments/migrations.ts | 36 +
.../tests/cases/comments/patch_comment.ts | 20 +-
.../tests/cases/comments/post_comment.ts | 4 +-
.../basic/tests/cases/delete_cases.ts | 12 +-
.../basic/tests/cases/find_cases.ts | 41 +-
.../basic/tests/cases/patch_cases.ts | 4 +
.../basic/tests/cases/push_case.ts | 4 +-
.../basic/tests/cases/status/get_status.ts | 4 +-
.../basic/tests/cases/tags/get_tags.ts | 3 +-
.../user_actions/get_all_user_actions.ts | 49 +-
.../basic/tests/connectors/case.ts | 763 +++++++++++++++
.../case_api_integration/basic/tests/index.ts | 1 +
.../case_api_integration/common/config.ts | 1 +
.../case_api_integration/common/lib/mock.ts | 21 +-
.../functional/es_archives/cases/data.json | 72 ++
60 files changed, 2621 insertions(+), 151 deletions(-)
create mode 100644 x-pack/plugins/case/server/connectors/case/index.test.ts
create mode 100644 x-pack/plugins/case/server/connectors/case/index.ts
create mode 100644 x-pack/plugins/case/server/connectors/case/schema.ts
create mode 100644 x-pack/plugins/case/server/connectors/case/translations.ts
create mode 100644 x-pack/plugins/case/server/connectors/case/types.ts
create mode 100644 x-pack/plugins/case/server/connectors/case/validators.ts
create mode 100644 x-pack/plugins/case/server/connectors/index.ts
create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts
create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts
create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/index.ts
create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts
create mode 100644 x-pack/test/case_api_integration/basic/tests/connectors/case.ts
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index eb44ad4d4eafa..3b1e4faf80bce 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -9,6 +9,7 @@
"xpack.apm": "plugins/apm",
"xpack.beatsManagement": "plugins/beats_management",
"xpack.canvas": "plugins/canvas",
+ "xpack.case": "plugins/case",
"xpack.cloud": "plugins/cloud",
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.discover": "plugins/discover_enhanced",
diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md
index 02e8e91c987d8..4fef9bc582d08 100644
--- a/x-pack/plugins/actions/README.md
+++ b/x-pack/plugins/actions/README.md
@@ -724,4 +724,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche
## user interface
-In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).
+In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).
\ No newline at end of file
diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md
index c0acb87835207..002fbfb8b53f7 100644
--- a/x-pack/plugins/case/README.md
+++ b/x-pack/plugins/case/README.md
@@ -7,3 +7,91 @@ Elastic is developing a Case Management Workflow. Follow our progress:
- [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest)
- [Github Meta](https://github.com/elastic/kibana/issues/50103)
+
+# Action types
+
+
+See [Kibana Actions](https://github.com/elastic/kibana/tree/master/x-pack/plugins/actions) for more information.
+
+## Case
+
+ID: `.case`
+
+The params properties are modelled after the arguments to the [Cases API](https://www.elastic.co/guide/en/security/master/cases-api-overview.html).
+
+### `config`
+
+This action has no `config` properties.
+
+### `secrets`
+
+This action type has no `secrets` properties.
+
+### `params`
+
+| Property | Description | Type |
+| --------------- | ------------------------------------------------------------------------- | ------ |
+| subAction | The sub action to perform. It can be `create`, `update`, and `addComment` | string |
+| subActionParams | The parameters of the sub action | object |
+
+#### `subActionParams (create)`
+
+| Property | Description | Type |
+| ----------- | --------------------------------------------------------------------- | ----------------------- |
+| tile | The case’s title. | string |
+| description | The case’s description. | string |
+| tags | String array containing words and phrases that help categorize cases. | string[] |
+| connector | Object containing the connector’s configuration. | [connector](#connector) |
+
+#### `subActionParams (update)`
+
+| Property | Description | Type |
+| ----------- | ---------------------------------------------------------- | ----------------------- |
+| id | The ID of the case being updated. | string |
+| tile | The updated case title. | string |
+| description | The updated case description. | string |
+| tags | The updated case tags. | string |
+| connector | Object containing the connector’s configuration. | [connector](#connector) |
+| status | The updated case status, which can be: `open` or `closed`. | string |
+| version | The current case version. | string |
+
+#### `subActionParams (addComment)`
+
+| Property | Description | Type |
+| -------- | --------------------------------------------------------- | ------ |
+| comment | The case’s new comment. | string |
+| type | The type of the comment, which can be: `user` or `alert`. | string |
+
+#### `connector`
+
+| Property | Description | Type |
+| -------- | ------------------------------------------------------------------------------------------------- | ----------------- |
+| id | ID of the connector used for pushing case updates to external systems. | string |
+| name | The connector name. | string |
+| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string |
+| fields | Object containing the connector’s fields. | [fields](#fields) |
+
+#### `fields`
+
+For ServiceNow connectors:
+
+| Property | Description | Type |
+| -------- | ----------------------------- | ------ |
+| urgency | The urgency of the incident. | string |
+| severity | The severity of the incident. | string |
+| impact | The impact of the incident. | string |
+
+For Jira connectors:
+
+| Property | Description | Type |
+| --------- | -------------------------------------------------------------------- | ------ |
+| issueType | The issue type of the issue. | string |
+| priority | The priority of the issue. | string |
+| parent | The key of the parent issue (Valid when the issue type is Sub-task). | string |
+
+For IBM Resilient connectors:
+
+| Property | Description | Type |
+| ------------ | ------------------------------- | -------- |
+| issueTypes | The issue types of the issue. | string[] |
+| severityCode | The severity code of the issue. | string |
diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts
index 4549b1c31a7cf..b4daac93940d8 100644
--- a/x-pack/plugins/case/common/api/cases/comment.ts
+++ b/x-pack/plugins/case/common/api/cases/comment.ts
@@ -10,6 +10,7 @@ import { UserRT } from '../user';
const CommentBasicRt = rt.type({
comment: rt.string,
+ type: rt.union([rt.literal('alert'), rt.literal('user')]),
});
export const CommentAttributesRt = rt.intersection([
@@ -37,7 +38,7 @@ export const CommentResponseRt = rt.intersection([
export const AllCommentsResponseRT = rt.array(CommentResponseRt);
export const CommentPatchRequestRt = rt.intersection([
- rt.partial(CommentRequestRt.props),
+ rt.partial(CommentBasicRt.props),
rt.type({ id: rt.string, version: rt.string }),
]);
@@ -48,6 +49,11 @@ export const CommentsResponseRt = rt.type({
total: rt.number,
});
+export enum CommentType {
+ user = 'user',
+ alert = 'alert',
+}
+
export const AllCommentsResponseRt = rt.array(CommentResponseRt);
export type CommentAttributes = rt.TypeOf;
diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts
index f253dd9f4feb4..d82979de2cb44 100644
--- a/x-pack/plugins/case/server/client/cases/create.test.ts
+++ b/x-pack/plugins/case/server/client/cases/create.test.ts
@@ -180,7 +180,7 @@ describe('create', () => {
describe('unhappy path', () => {
test('it throws when missing title', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
description: 'This is a brand new case of a bad meanie defacing data',
tags: ['defacement'],
@@ -199,11 +199,15 @@ describe('create', () => {
caseClient.client
// @ts-expect-error
.create({ theCase: postCase })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when missing description', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
title: 'a title',
tags: ['defacement'],
@@ -222,11 +226,15 @@ describe('create', () => {
caseClient.client
// @ts-expect-error
.create({ theCase: postCase })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when missing tags', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
@@ -245,11 +253,15 @@ describe('create', () => {
caseClient.client
// @ts-expect-error
.create({ theCase: postCase })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when missing connector ', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
@@ -263,11 +275,15 @@ describe('create', () => {
caseClient.client
// @ts-expect-error
.create({ theCase: postCase })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when connector missing the right fields', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
@@ -287,11 +303,15 @@ describe('create', () => {
caseClient.client
// @ts-expect-error
.create({ theCase: postCase })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws if you passing status for a new case', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const postCase = {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
@@ -309,7 +329,11 @@ describe('create', () => {
caseSavedObject: mockCases,
});
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
- caseClient.client.create({ theCase: postCase }).catch((e) => expect(e).not.toBeNull());
+ caseClient.client.create({ theCase: postCase }).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
it(`Returns an error if postNewCase throws`, async () => {
@@ -329,7 +353,11 @@ describe('create', () => {
});
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
- caseClient.client.create({ theCase: postCase }).catch((e) => expect(e).not.toBeNull());
+ caseClient.client.create({ theCase: postCase }).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
});
});
diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts
index 62d897999c11a..10eebd1210a9e 100644
--- a/x-pack/plugins/case/server/client/cases/update.test.ts
+++ b/x-pack/plugins/case/server/client/cases/update.test.ts
@@ -247,7 +247,7 @@ describe('update', () => {
describe('unhappy path', () => {
test('it throws when missing id', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const patchCases = {
cases: [
{
@@ -270,11 +270,15 @@ describe('update', () => {
caseClient.client
// @ts-expect-error
.update({ cases: patchCases })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when missing version', async () => {
- expect.assertions(1);
+ expect.assertions(3);
const patchCases = {
cases: [
{
@@ -297,11 +301,15 @@ describe('update', () => {
caseClient.client
// @ts-expect-error
.update({ cases: patchCases })
- .catch((e) => expect(e).not.toBeNull());
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
});
test('it throws when fields are identical', async () => {
- expect.assertions(1);
+ expect.assertions(4);
const patchCases = {
cases: [
{
@@ -317,14 +325,16 @@ describe('update', () => {
});
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
- caseClient.client
- .update({ cases: patchCases })
- .catch((e) =>
- expect(e.message).toBe('All update fields are identical to current version.')
- );
+ caseClient.client.update({ cases: patchCases }).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(406);
+ expect(e.message).toBe('All update fields are identical to current version.');
+ });
});
test('it throws when case does not exist', async () => {
+ expect.assertions(4);
const patchCases = {
cases: [
{
@@ -345,17 +355,18 @@ describe('update', () => {
});
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
- caseClient.client
- .update({ cases: patchCases })
- .catch((e) =>
- expect(e.message).toBe(
- 'These cases not-exists do not exist. Please check you have the correct ids.'
- )
+ caseClient.client.update({ cases: patchCases }).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(404);
+ expect(e.message).toBe(
+ 'These cases not-exists do not exist. Please check you have the correct ids.'
);
+ });
});
test('it throws when cases conflicts', async () => {
- expect.assertions(1);
+ expect.assertions(4);
const patchCases = {
cases: [
{
@@ -371,13 +382,14 @@ describe('update', () => {
});
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
- caseClient.client
- .update({ cases: patchCases })
- .catch((e) =>
- expect(e.message).toBe(
- 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.'
- )
+ caseClient.client.update({ cases: patchCases }).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(409);
+ expect(e.message).toBe(
+ 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.'
);
+ });
});
});
});
diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts
index 8a316740e41e0..50e104b30178a 100644
--- a/x-pack/plugins/case/server/client/comments/add.test.ts
+++ b/x-pack/plugins/case/server/client/comments/add.test.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { CommentType } from '../../../common/api';
import {
createMockSavedObjectsRepository,
mockCaseComments,
@@ -30,13 +31,14 @@ describe('addComment', () => {
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
const res = await caseClient.client.addComment({
caseId: 'mock-id-1',
- comment: { comment: 'Wow, good luck catching that bad meanie!' },
+ comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user },
});
expect(res.id).toEqual('mock-id-1');
expect(res.totalComment).toEqual(res.comments!.length);
expect(res.comments![res.comments!.length - 1]).toEqual({
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
created_at: '2020-10-23T21:54:48.952Z',
created_by: {
email: 'd00d@awesome.com',
@@ -61,7 +63,7 @@ describe('addComment', () => {
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
const res = await caseClient.client.addComment({
caseId: 'mock-id-1',
- comment: { comment: 'Wow, good luck catching that bad meanie!' },
+ comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user },
});
expect(res.updated_at).toEqual('2020-10-23T21:54:48.952Z');
@@ -81,7 +83,7 @@ describe('addComment', () => {
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
await caseClient.client.addComment({
caseId: 'mock-id-1',
- comment: { comment: 'Wow, good luck catching that bad meanie!' },
+ comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user },
});
expect(
@@ -125,12 +127,13 @@ describe('addComment', () => {
const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true);
const res = await caseClient.client.addComment({
caseId: 'mock-id-1',
- comment: { comment: 'Wow, good luck catching that bad meanie!' },
+ comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user },
});
expect(res.id).toEqual('mock-id-1');
expect(res.comments![res.comments!.length - 1]).toEqual({
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
created_at: '2020-10-23T21:54:48.952Z',
created_by: {
email: null,
@@ -169,6 +172,27 @@ describe('addComment', () => {
});
});
+ test('it throws when missing comment type', async () => {
+ expect.assertions(3);
+
+ const savedObjectsClient = createMockSavedObjectsRepository({
+ caseSavedObject: mockCases,
+ caseCommentSavedObject: mockCaseComments,
+ });
+ const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient);
+ caseClient.client
+ .addComment({
+ caseId: 'mock-id-1',
+ // @ts-expect-error
+ comment: { comment: 'a comment' },
+ })
+ .catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.isBoom).toBe(true);
+ expect(e.output.statusCode).toBe(400);
+ });
+ });
+
test('it throws when the case does not exists', async () => {
expect.assertions(3);
@@ -180,7 +204,7 @@ describe('addComment', () => {
caseClient.client
.addComment({
caseId: 'not-exists',
- comment: { comment: 'Wow, good luck catching that bad meanie!' },
+ comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user },
})
.catch((e) => {
expect(e).not.toBeNull();
@@ -200,7 +224,7 @@ describe('addComment', () => {
caseClient.client
.addComment({
caseId: 'mock-id-1',
- comment: { comment: 'Throw an error' },
+ comment: { comment: 'Throw an error', type: CommentType.user },
})
.catch((e) => {
expect(e).not.toBeNull();
diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts
new file mode 100644
index 0000000000000..e14281e047915
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/index.test.ts
@@ -0,0 +1,891 @@
+/*
+ * 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 { Logger } from '../../../../../../src/core/server';
+import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
+import { actionsMock } from '../../../../actions/server/mocks';
+import { validateParams } from '../../../../actions/server/lib';
+import { ConnectorTypes, CommentType } from '../../../common/api';
+import {
+ createCaseServiceMock,
+ createConfigureServiceMock,
+ createUserActionServiceMock,
+} from '../../services/mocks';
+import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types';
+import { getActionType } from '.';
+import { createCaseClientMock } from '../../client/mocks';
+
+const mockCaseClient = createCaseClientMock();
+
+jest.mock('../../client', () => ({
+ createCaseClient: () => mockCaseClient,
+}));
+
+const services = actionsMock.createServices();
+let caseActionType: CaseActionType;
+
+describe('case connector', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ const logger = loggingSystemMock.create().get() as jest.Mocked;
+ const caseService = createCaseServiceMock();
+ const caseConfigureService = createConfigureServiceMock();
+ const userActionService = createUserActionServiceMock();
+ caseActionType = getActionType({
+ logger,
+ caseService,
+ caseConfigureService,
+ userActionService,
+ });
+ });
+
+ describe('params validation', () => {
+ describe('create', () => {
+ it('succeeds when params is valid', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual(params);
+ });
+
+ it('fails when params is not valid', () => {
+ const params: Record = {
+ subAction: 'create',
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow();
+ });
+
+ describe('connector', () => {
+ const connectorTests = [
+ {
+ test: 'jira',
+ params: {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ },
+ },
+ {
+ test: 'resilient',
+ params: {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'resilient',
+ name: 'Resilient',
+ type: '.resilient',
+ fields: {
+ incidentTypes: ['13'],
+ severityCode: '3',
+ },
+ },
+ },
+ },
+ },
+ {
+ test: 'servicenow',
+ params: {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: 'Medium',
+ severity: 'Medium',
+ urgency: 'Medium',
+ },
+ },
+ },
+ },
+ },
+ {
+ test: 'none',
+ params: {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: null,
+ },
+ },
+ },
+ },
+ ];
+
+ connectorTests.forEach(({ params, test }) => {
+ it(`succeeds when ${test} fields are valid`, () => {
+ expect(validateParams(caseActionType, params)).toEqual(params);
+ });
+ });
+
+ it('set fields to null if they are missing', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {},
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: { impact: null, severity: null, urgency: null },
+ },
+ },
+ });
+ });
+
+ it('succeeds when none fields are valid', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: null,
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual(params);
+ });
+
+ it('fails when issueType is not provided', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[0.subActionParams.connector.fields.issueType]: expected value of type [string] but got [undefined]'
+ );
+ });
+
+ it('fails with excess fields', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: 'Medium',
+ severity: 'Medium',
+ urgency: 'Medium',
+ excess: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[0.subActionParams.connector.fields.excess]: definition for this key is missing'
+ );
+ });
+
+ it('fails with valid fields but wrong type', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'resilient',
+ name: 'Resilient',
+ type: '.resilient',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[0.subActionParams.connector.fields.issueType]: definition for this key is missing'
+ );
+ });
+
+ it('fails when fields are not null and the type is none', () => {
+ const params: Record = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: {},
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[0.subActionParams.connector]: Fields must be set to null for connectors of type .none'
+ );
+ });
+ });
+ });
+
+ describe('update', () => {
+ it('succeeds when params is valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ title: 'Update title',
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ connector: null,
+ ...(params.subActionParams as Record),
+ },
+ });
+ });
+
+ describe('connector', () => {
+ it('succeeds when jira fields are valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ ...(params.subActionParams as Record),
+ },
+ });
+ });
+
+ it('succeeds when resilient fields are valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'resilient',
+ name: 'Resilient',
+ type: '.resilient',
+ fields: {
+ incidentTypes: ['13'],
+ severityCode: '3',
+ },
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ ...(params.subActionParams as Record),
+ },
+ });
+ });
+
+ it('succeeds when servicenow fields are valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: 'Medium',
+ severity: 'Medium',
+ urgency: 'Medium',
+ },
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ ...(params.subActionParams as Record),
+ },
+ });
+ });
+
+ it('set fields to null if they are missing', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {},
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: { impact: null, severity: null, urgency: null },
+ },
+ },
+ });
+ });
+
+ it('succeeds when none fields are valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: null,
+ },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual({
+ ...params,
+ subActionParams: {
+ description: null,
+ tags: null,
+ title: null,
+ status: null,
+ ...(params.subActionParams as Record),
+ },
+ });
+ });
+
+ it('fails when issueType is not provided', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[subActionParams.connector.0.fields.issueType]: expected value of type [string] but got [undefined]'
+ );
+ });
+
+ it('fails with excess fields', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: 'Medium',
+ severity: 'Medium',
+ urgency: 'Medium',
+ excess: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[subActionParams.connector.0.fields.excess]: definition for this key is missing'
+ );
+ });
+
+ it('fails with valid fields but wrong type', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'resilient',
+ name: 'Resilient',
+ type: '.resilient',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[subActionParams.connector.0.fields.issueType]: definition for this key is missing'
+ );
+ });
+
+ it('fails when fields are not null and the type is none', () => {
+ const params: Record = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: {},
+ },
+ },
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow(
+ '[subActionParams.connector.0]: Fields must be set to null for connectors of type .none'
+ );
+ });
+ });
+
+ it('fails when params is not valid', () => {
+ const params: Record = {
+ subAction: 'update',
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow();
+ });
+ });
+
+ describe('add comment', () => {
+ it('succeeds when params is valid', () => {
+ const params: Record = {
+ subAction: 'addComment',
+ subActionParams: {
+ caseId: 'case-id',
+ comment: { comment: 'a comment', type: CommentType.user },
+ },
+ };
+
+ expect(validateParams(caseActionType, params)).toEqual(params);
+ });
+
+ it('fails when params is not valid', () => {
+ const params: Record = {
+ subAction: 'addComment',
+ };
+
+ expect(() => {
+ validateParams(caseActionType, params);
+ }).toThrow();
+ });
+ });
+ });
+
+ describe('execute', () => {
+ it('allows only supported sub-actions', async () => {
+ expect.assertions(2);
+ const actionId = 'some-id';
+ const params: CaseExecutorParams = {
+ // @ts-expect-error
+ subAction: 'not-supported',
+ // @ts-expect-error
+ subActionParams: {},
+ };
+
+ const executorOptions: CaseActionTypeExecutorOptions = {
+ actionId,
+ config: {},
+ params,
+ secrets: {},
+ services,
+ };
+
+ caseActionType.executor(executorOptions).catch((e) => {
+ expect(e).not.toBeNull();
+ expect(e.message).toBe('[Action][Case] subAction not-supported not implemented.');
+ });
+ });
+
+ describe('create', () => {
+ it('executes correctly', async () => {
+ const createReturn = {
+ id: 'mock-it',
+ comments: [],
+ totalComment: 0,
+ closed_at: null,
+ closed_by: null,
+ connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null },
+ created_at: '2019-11-25T21:54:48.952Z',
+ created_by: {
+ full_name: 'Awesome D00d',
+ email: 'd00d@awesome.com',
+ username: 'awesome',
+ },
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ external_service: null,
+ status: 'open' as const,
+ updated_at: null,
+ updated_by: null,
+ version: 'WzksMV0=',
+ };
+
+ mockCaseClient.create.mockReturnValue(Promise.resolve(createReturn));
+
+ const actionId = 'some-id';
+ const params: CaseExecutorParams = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'Yo fields!!',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const executorOptions: CaseActionTypeExecutorOptions = {
+ actionId,
+ config: {},
+ params,
+ secrets: {},
+ services,
+ };
+
+ const result = await caseActionType.executor(executorOptions);
+
+ expect(result).toEqual({ actionId, status: 'ok', data: createReturn });
+ expect(mockCaseClient.create).toHaveBeenCalledWith({
+ theCase: {
+ ...params.subActionParams,
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ });
+ });
+ });
+
+ describe('update', () => {
+ it('executes correctly', async () => {
+ const updateReturn = [
+ {
+ closed_at: '2019-11-25T21:54:48.952Z',
+ closed_by: {
+ email: 'd00d@awesome.com',
+ full_name: 'Awesome D00d',
+ username: 'awesome',
+ },
+ comments: [],
+ connector: {
+ id: 'none',
+ name: 'none',
+ type: ConnectorTypes.none,
+ fields: null,
+ },
+ created_at: '2019-11-25T21:54:48.952Z',
+ created_by: {
+ email: 'testemail@elastic.co',
+ full_name: 'elastic',
+ username: 'elastic',
+ },
+ description: 'This is a brand new case of a bad meanie defacing data',
+ id: 'mock-id-1',
+ external_service: null,
+ status: 'open' as const,
+ tags: ['defacement'],
+ title: 'Update title',
+ totalComment: 0,
+ updated_at: '2019-11-25T21:54:48.952Z',
+ updated_by: {
+ email: 'd00d@awesome.com',
+ full_name: 'Awesome D00d',
+ username: 'awesome',
+ },
+ version: 'WzE3LDFd',
+ },
+ ];
+
+ mockCaseClient.update.mockReturnValue(Promise.resolve(updateReturn));
+
+ const actionId = 'some-id';
+ const params: CaseExecutorParams = {
+ subAction: 'update',
+ subActionParams: {
+ id: 'case-id',
+ version: '123',
+ title: 'Update title',
+ description: null,
+ tags: null,
+ status: null,
+ connector: null,
+ },
+ };
+
+ const executorOptions: CaseActionTypeExecutorOptions = {
+ actionId,
+ config: {},
+ params,
+ secrets: {},
+ services,
+ };
+
+ const result = await caseActionType.executor(executorOptions);
+
+ expect(result).toEqual({ actionId, status: 'ok', data: updateReturn });
+ expect(mockCaseClient.update).toHaveBeenCalledWith({
+ // Null values have been striped out.
+ cases: {
+ cases: [
+ {
+ id: 'case-id',
+ version: '123',
+ title: 'Update title',
+ },
+ ],
+ },
+ });
+ });
+ });
+
+ describe('addComment', () => {
+ it('executes correctly', async () => {
+ const commentReturn = {
+ id: 'mock-it',
+ totalComment: 0,
+ closed_at: null,
+ closed_by: null,
+ connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null },
+ created_at: '2019-11-25T21:54:48.952Z',
+ created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' },
+ description: 'This is a brand new case of a bad meanie defacing data',
+ external_service: null,
+ title: 'Super Bad Security Issue',
+ status: 'open' as const,
+ tags: ['defacement'],
+ updated_at: null,
+ updated_by: null,
+ version: 'WzksMV0=',
+ comments: [
+ {
+ comment: 'a comment',
+ type: CommentType.user as const,
+ created_at: '2020-10-23T21:54:48.952Z',
+ created_by: {
+ email: 'd00d@awesome.com',
+ full_name: 'Awesome D00d',
+ username: 'awesome',
+ },
+ id: 'mock-comment',
+ pushed_at: null,
+ pushed_by: null,
+ updated_at: null,
+ updated_by: null,
+ version: 'WzksMV0=',
+ },
+ ],
+ };
+
+ mockCaseClient.addComment.mockReturnValue(Promise.resolve(commentReturn));
+
+ const actionId = 'some-id';
+ const params: CaseExecutorParams = {
+ subAction: 'addComment',
+ subActionParams: {
+ caseId: 'case-id',
+ comment: { comment: 'a comment', type: CommentType.user },
+ },
+ };
+
+ const executorOptions: CaseActionTypeExecutorOptions = {
+ actionId,
+ config: {},
+ params,
+ secrets: {},
+ services,
+ };
+
+ const result = await caseActionType.executor(executorOptions);
+
+ expect(result).toEqual({ actionId, status: 'ok', data: commentReturn });
+ expect(mockCaseClient.addComment).toHaveBeenCalledWith({
+ caseId: 'case-id',
+ comment: { comment: 'a comment', type: CommentType.user },
+ });
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts
new file mode 100644
index 0000000000000..f284f0ed9668c
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/index.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { curry } from 'lodash';
+
+import { KibanaRequest } from 'kibana/server';
+import { ActionTypeExecutorResult } from '../../../../actions/common';
+import { CasePatchRequest, CasePostRequest } from '../../../common/api';
+import { createCaseClient } from '../../client';
+import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema';
+import {
+ CaseExecutorResponse,
+ ExecutorSubActionAddCommentParams,
+ CaseActionType,
+ CaseActionTypeExecutorOptions,
+} from './types';
+import * as i18n from './translations';
+
+import { GetActionTypeParams } from '..';
+
+const supportedSubActions: string[] = ['create', 'update', 'addComment'];
+
+// action type definition
+export function getActionType({
+ logger,
+ caseService,
+ caseConfigureService,
+ userActionService,
+}: GetActionTypeParams): CaseActionType {
+ return {
+ id: '.case',
+ minimumLicenseRequired: 'gold',
+ name: i18n.NAME,
+ validate: {
+ config: CaseConfigurationSchema,
+ params: CaseExecutorParamsSchema,
+ },
+ executor: curry(executor)({ logger, caseService, caseConfigureService, userActionService }),
+ };
+}
+
+// action executor
+async function executor(
+ { logger, caseService, caseConfigureService, userActionService }: GetActionTypeParams,
+ execOptions: CaseActionTypeExecutorOptions
+): Promise> {
+ const { actionId, params, services } = execOptions;
+ const { subAction, subActionParams } = params;
+ let data: CaseExecutorResponse | null = null;
+
+ const { savedObjectsClient } = services;
+ const caseClient = createCaseClient({
+ savedObjectsClient,
+ request: {} as KibanaRequest,
+ caseService,
+ caseConfigureService,
+ userActionService,
+ });
+
+ if (!supportedSubActions.includes(subAction)) {
+ const errorMessage = `[Action][Case] subAction ${subAction} not implemented.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (subAction === 'create') {
+ data = await caseClient.create({ theCase: subActionParams as CasePostRequest });
+ }
+
+ if (subAction === 'update') {
+ const updateParamsWithoutNullValues = Object.entries(subActionParams).reduce(
+ (acc, [key, value]) => ({
+ ...acc,
+ ...(value != null ? { [key]: value } : {}),
+ }),
+ {} as CasePatchRequest
+ );
+
+ data = await caseClient.update({ cases: { cases: [updateParamsWithoutNullValues] } });
+ }
+
+ if (subAction === 'addComment') {
+ const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams;
+ data = await caseClient.addComment({ caseId, comment });
+ }
+
+ return { status: 'ok', data: data ?? {}, actionId };
+}
diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts
new file mode 100644
index 0000000000000..aa503e96be30d
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/schema.ts
@@ -0,0 +1,109 @@
+/*
+ * 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 { validateConnector } from './validators';
+
+// Reserved for future implementation
+export const CaseConfigurationSchema = schema.object({});
+
+const CommentProps = {
+ comment: schema.string(),
+ type: schema.oneOf([schema.literal('alert'), schema.literal('user')]),
+};
+
+const JiraFieldsSchema = schema.object({
+ issueType: schema.string(),
+ priority: schema.nullable(schema.string()),
+ parent: schema.nullable(schema.string()),
+});
+
+const ResilientFieldsSchema = schema.object({
+ incidentTypes: schema.nullable(schema.arrayOf(schema.string())),
+ severityCode: schema.nullable(schema.string()),
+});
+
+const ServiceNowFieldsSchema = schema.object({
+ impact: schema.nullable(schema.string()),
+ severity: schema.nullable(schema.string()),
+ urgency: schema.nullable(schema.string()),
+});
+
+const NoneFieldsSchema = schema.nullable(schema.object({}));
+
+const ReducedConnectorFieldsSchema: { [x: string]: any } = {
+ '.jira': JiraFieldsSchema,
+ '.resilient': ResilientFieldsSchema,
+};
+
+export const ConnectorProps = {
+ id: schema.string(),
+ name: schema.string(),
+ type: schema.oneOf([
+ schema.literal('.servicenow'),
+ schema.literal('.jira'),
+ schema.literal('.resilient'),
+ schema.literal('.none'),
+ ]),
+ // Chain of conditional schemes
+ fields: Object.keys(ReducedConnectorFieldsSchema).reduce(
+ (conditionalSchema, key) =>
+ schema.conditional(
+ schema.siblingRef('type'),
+ key,
+ ReducedConnectorFieldsSchema[key],
+ conditionalSchema
+ ),
+ schema.conditional(
+ schema.siblingRef('type'),
+ '.servicenow',
+ ServiceNowFieldsSchema,
+ NoneFieldsSchema
+ )
+ ),
+};
+
+export const ConnectorSchema = schema.object(ConnectorProps);
+
+const CaseBasicProps = {
+ description: schema.string(),
+ title: schema.string(),
+ tags: schema.arrayOf(schema.string()),
+ connector: schema.object(ConnectorProps, { validate: validateConnector }),
+};
+
+const CaseUpdateRequestProps = {
+ id: schema.string(),
+ version: schema.string(),
+ description: schema.nullable(CaseBasicProps.description),
+ title: schema.nullable(CaseBasicProps.title),
+ tags: schema.nullable(CaseBasicProps.tags),
+ connector: schema.nullable(CaseBasicProps.connector),
+ status: schema.nullable(schema.string()),
+};
+
+const CaseAddCommentRequestProps = {
+ caseId: schema.string(),
+ comment: schema.object(CommentProps),
+};
+
+export const ExecutorSubActionCreateParamsSchema = schema.object(CaseBasicProps);
+export const ExecutorSubActionUpdateParamsSchema = schema.object(CaseUpdateRequestProps);
+export const ExecutorSubActionAddCommentParamsSchema = schema.object(CaseAddCommentRequestProps);
+
+export const CaseExecutorParamsSchema = schema.oneOf([
+ schema.object({
+ subAction: schema.literal('create'),
+ subActionParams: ExecutorSubActionCreateParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('update'),
+ subActionParams: ExecutorSubActionUpdateParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('addComment'),
+ subActionParams: ExecutorSubActionAddCommentParamsSchema,
+ }),
+]);
diff --git a/x-pack/plugins/case/server/connectors/case/translations.ts b/x-pack/plugins/case/server/connectors/case/translations.ts
new file mode 100644
index 0000000000000..9356ea8a31797
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/translations.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const NAME = i18n.translate('xpack.case.connectors.case.title', {
+ defaultMessage: 'Case',
+});
diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts
new file mode 100644
index 0000000000000..b3a05163fa6f4
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/types.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { TypeOf } from '@kbn/config-schema';
+import { ActionType, ActionTypeExecutorOptions } from '../../../../actions/server';
+import {
+ CaseExecutorParamsSchema,
+ ExecutorSubActionCreateParamsSchema,
+ ExecutorSubActionUpdateParamsSchema,
+ CaseConfigurationSchema,
+ ExecutorSubActionAddCommentParamsSchema,
+ ConnectorSchema,
+} from './schema';
+import { CaseResponse, CasesResponse } from '../../../common/api';
+
+export type CaseConfiguration = TypeOf;
+export type Connector = TypeOf;
+
+export type ExecutorSubActionCreateParams = TypeOf;
+export type ExecutorSubActionUpdateParams = TypeOf;
+export type ExecutorSubActionAddCommentParams = TypeOf<
+ typeof ExecutorSubActionAddCommentParamsSchema
+>;
+
+export type CaseExecutorParams = TypeOf;
+export type CaseExecutorResponse = CaseResponse | CasesResponse;
+
+export type CaseActionType = ActionType<
+ CaseConfiguration,
+ {},
+ CaseExecutorParams,
+ CaseExecutorResponse | {}
+>;
+
+export type CaseActionTypeExecutorOptions = ActionTypeExecutorOptions<
+ CaseConfiguration,
+ {},
+ CaseExecutorParams
+>;
diff --git a/x-pack/plugins/case/server/connectors/case/validators.ts b/x-pack/plugins/case/server/connectors/case/validators.ts
new file mode 100644
index 0000000000000..f8330492d4366
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/case/validators.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Connector } from './types';
+
+export const validateConnector = (connector: Connector) => {
+ if (connector.type === '.none' && connector.fields !== null) {
+ return 'Fields must be set to null for connectors of type .none';
+ }
+};
diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts
new file mode 100644
index 0000000000000..6a97a9e6e8a8a
--- /dev/null
+++ b/x-pack/plugins/case/server/connectors/index.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Logger } from 'kibana/server';
+import {
+ ActionTypeConfig,
+ ActionTypeSecrets,
+ ActionTypeParams,
+ ActionType,
+ // eslint-disable-next-line @kbn/eslint/no-restricted-paths
+} from '../../../actions/server/types';
+import {
+ CaseServiceSetup,
+ CaseConfigureServiceSetup,
+ CaseUserActionServiceSetup,
+} from '../services';
+
+import { getActionType as getCaseConnector } from './case';
+
+export interface GetActionTypeParams {
+ logger: Logger;
+ caseService: CaseServiceSetup;
+ caseConfigureService: CaseConfigureServiceSetup;
+ userActionService: CaseUserActionServiceSetup;
+}
+
+export interface RegisterConnectorsArgs extends GetActionTypeParams {
+ actionsRegisterType<
+ Config extends ActionTypeConfig = ActionTypeConfig,
+ Secrets extends ActionTypeSecrets = ActionTypeSecrets,
+ Params extends ActionTypeParams = ActionTypeParams,
+ ExecutorResultData = void
+ >(
+ actionType: ActionType
+ ): void;
+}
+
+export const registerConnectors = ({
+ actionsRegisterType,
+ logger,
+ caseService,
+ caseConfigureService,
+ userActionService,
+}: RegisterConnectorsArgs) => {
+ actionsRegisterType(
+ getCaseConnector({
+ logger,
+ caseService,
+ caseConfigureService,
+ userActionService,
+ })
+ );
+};
diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts
index 5398f8ed0ae83..64c4b422d1cf7 100644
--- a/x-pack/plugins/case/server/plugin.ts
+++ b/x-pack/plugins/case/server/plugin.ts
@@ -15,6 +15,7 @@ import {
import { CoreSetup, CoreStart } from 'src/core/server';
import { SecurityPluginSetup } from '../../security/server';
+import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server';
import { APP_ID } from '../common/constants';
import { ConfigType } from './config';
@@ -34,6 +35,7 @@ import {
CaseUserActionServiceSetup,
} from './services';
import { createCaseClient } from './client';
+import { registerConnectors } from './connectors';
function createConfig$(context: PluginInitializerContext) {
return context.config.create().pipe(map((config) => config));
@@ -41,6 +43,7 @@ function createConfig$(context: PluginInitializerContext) {
export interface PluginsSetup {
security: SecurityPluginSetup;
+ actions: ActionsPluginSetup;
}
export class CasePlugin {
@@ -94,6 +97,14 @@ export class CasePlugin {
userActionService: this.userActionService,
router,
});
+
+ registerConnectors({
+ actionsRegisterType: plugins.actions.registerType,
+ logger: this.log,
+ caseService: this.caseService,
+ caseConfigureService: this.caseConfigureService,
+ userActionService: this.userActionService,
+ });
}
public async start(core: CoreStart) {
diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
index e7ea381da9955..9314ebb445820 100644
--- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
+++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
@@ -10,6 +10,7 @@ import {
CommentAttributes,
ESCaseAttributes,
ConnectorTypes,
+ CommentType,
} from '../../../../common/api';
export const mockCases: Array> = [
@@ -207,6 +208,7 @@ export const mockCaseComments: Array> = [
id: 'mock-comment-1',
attributes: {
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
@@ -237,6 +239,7 @@ export const mockCaseComments: Array> = [
id: 'mock-comment-2',
attributes: {
comment: 'Well I decided to update my comment. So what? Deal with it.',
+ type: CommentType.user,
created_at: '2019-11-25T21:55:14.633Z',
created_by: {
full_name: 'elastic',
@@ -268,6 +271,7 @@ export const mockCaseComments: Array> = [
id: 'mock-comment-3',
attributes: {
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
created_at: '2019-11-25T22:32:30.608Z',
created_by: {
full_name: 'elastic',
diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts
index acc23815e3a39..0b733bb034f8c 100644
--- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts
@@ -16,6 +16,7 @@ import {
} from '../../__fixtures__';
import { initPostCommentApi } from './post_comment';
import { CASE_COMMENTS_URL } from '../../../../../common/constants';
+import { CommentType } from '../../../../../common/api';
describe('POST comment', () => {
let routeHandler: RequestHandler;
@@ -36,6 +37,7 @@ describe('POST comment', () => {
},
body: {
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
},
});
@@ -62,6 +64,7 @@ describe('POST comment', () => {
},
body: {
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
},
});
@@ -112,6 +115,7 @@ describe('POST comment', () => {
},
body: {
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
},
});
@@ -127,6 +131,7 @@ describe('POST comment', () => {
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({
comment: 'Wow, good luck catching that bad meanie!',
+ type: CommentType.user,
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
email: null,
diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts
index 00584a9d7431f..fc1086b03814b 100644
--- a/x-pack/plugins/case/server/routes/api/utils.test.ts
+++ b/x-pack/plugins/case/server/routes/api/utils.test.ts
@@ -23,7 +23,7 @@ import {
mockCaseComments,
mockCaseNoConnectorId,
} from './__fixtures__/mock_saved_objects';
-import { ConnectorTypes, ESCaseConnector } from '../../../common/api';
+import { ConnectorTypes, ESCaseConnector, CommentType } from '../../../common/api';
describe('Utils', () => {
describe('transformNewCase', () => {
@@ -117,6 +117,7 @@ describe('Utils', () => {
it('transforms correctly', () => {
const comment = {
comment: 'A comment',
+ type: CommentType.user,
createdDate: '2020-04-09T09:43:51.778Z',
email: 'elastic@elastic.co',
full_name: 'Elastic',
@@ -126,6 +127,7 @@ describe('Utils', () => {
const res = transformNewComment(comment);
expect(res).toEqual({
comment: 'A comment',
+ type: CommentType.user,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' },
pushed_at: null,
@@ -138,6 +140,7 @@ describe('Utils', () => {
it('transform correctly without optional fields', () => {
const comment = {
comment: 'A comment',
+ type: CommentType.user,
createdDate: '2020-04-09T09:43:51.778Z',
};
@@ -145,6 +148,7 @@ describe('Utils', () => {
expect(res).toEqual({
comment: 'A comment',
+ type: CommentType.user,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: undefined, full_name: undefined, username: undefined },
pushed_at: null,
@@ -157,6 +161,7 @@ describe('Utils', () => {
it('transform correctly with optional fields as null', () => {
const comment = {
comment: 'A comment',
+ type: CommentType.user,
createdDate: '2020-04-09T09:43:51.778Z',
email: null,
full_name: null,
@@ -167,6 +172,7 @@ describe('Utils', () => {
expect(res).toEqual({
comment: 'A comment',
+ type: CommentType.user,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: null, full_name: null, username: null },
pushed_at: null,
diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts
index 3f82dac96a70e..f8fe149c2ff2f 100644
--- a/x-pack/plugins/case/server/routes/api/utils.ts
+++ b/x-pack/plugins/case/server/routes/api/utils.ts
@@ -22,6 +22,7 @@ import {
CommentAttributes,
ESCaseConnector,
ESCaseAttributes,
+ CommentRequest,
} from '../../../common/api';
import { transformESConnectorToCaseConnector } from './cases/helpers';
@@ -55,15 +56,16 @@ export const transformNewCase = ({
updated_by: null,
});
-interface NewCommentArgs {
- comment: string;
+interface NewCommentArgs extends CommentRequest {
createdDate: string;
email?: string | null;
full_name?: string | null;
username?: string | null;
}
+
export const transformNewComment = ({
comment,
+ type,
createdDate,
email,
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -71,6 +73,7 @@ export const transformNewComment = ({
username,
}: NewCommentArgs): CommentAttributes => ({
comment,
+ type,
created_at: createdDate,
created_by: { email, full_name, username },
pushed_at: null,
diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts
index 8b69f272d5b0d..87478eb23641f 100644
--- a/x-pack/plugins/case/server/saved_object_types/comments.ts
+++ b/x-pack/plugins/case/server/saved_object_types/comments.ts
@@ -5,6 +5,7 @@
*/
import { SavedObjectsType } from 'src/core/server';
+import { commentsMigrations } from './migrations';
export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments';
@@ -17,6 +18,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
comment: {
type: 'text',
},
+ type: {
+ type: 'keyword',
+ },
created_at: {
type: 'date',
},
@@ -67,4 +71,5 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
},
},
},
+ migrations: commentsMigrations,
};
diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts
index c3dd88799b5fb..27c363a40af37 100644
--- a/x-pack/plugins/case/server/saved_object_types/migrations.ts
+++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts
@@ -7,7 +7,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server';
-import { ConnectorTypes } from '../../common/api/connectors';
+import { ConnectorTypes, CommentType } from '../../common/api';
interface UnsanitizedCase {
connector_id: string;
@@ -126,3 +126,27 @@ export const userActionsMigrations = {
};
},
};
+
+interface UnsanitizedComment {
+ comment: string;
+}
+
+interface SanitizedComment {
+ comment: string;
+ type: CommentType;
+}
+
+export const commentsMigrations = {
+ '7.11.0': (
+ doc: SavedObjectUnsanitizedDoc
+ ): SavedObjectSanitizedDoc => {
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ type: CommentType.user,
+ },
+ references: doc.references || [],
+ };
+ },
+};
diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx
index a85d7a310bc06..2c8051f902b17 100644
--- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx
@@ -12,6 +12,7 @@ import { TestProviders } from '../../../common/mock';
import { getFormMock } from '../__mock__/form';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
+import { CommentRequest, CommentType } from '../../../../../case/common/api';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { usePostComment } from '../../containers/use_post_comment';
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
@@ -66,8 +67,9 @@ const defaultPostCommment = {
postComment,
};
-const sampleData = {
+const sampleData: CommentRequest = {
comment: 'what a cool comment',
+ type: CommentType.user,
};
describe('AddComment ', () => {
diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
index 5b77c4d99a951..c54bd8b621d83 100644
--- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
@@ -8,7 +8,7 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
import React, { useCallback, forwardRef, useImperativeHandle } from 'react';
import styled from 'styled-components';
-import { CommentRequest } from '../../../../../case/common/api';
+import { CommentRequest, CommentType } from '../../../../../case/common/api';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
@@ -27,6 +27,7 @@ const MySpinner = styled(EuiLoadingSpinner)`
const initialCommentValue: CommentRequest = {
comment: '',
+ type: CommentType.user,
};
export interface AddCommentRefObject {
@@ -81,7 +82,7 @@ export const AddComment = React.memo(
if (onCommentSaving != null) {
onCommentSaving();
}
- postComment(data, onCommentPosted);
+ postComment({ ...data, type: CommentType.user }, onCommentPosted);
reset();
}
}, [onCommentPosted, onCommentSaving, postComment, reset, submit]);
diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
index 5c6c72477bf1f..42b97d5f6130f 100644
--- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx
@@ -82,11 +82,11 @@ export const getCasesColumns = (
<>
- {createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''}
+ {createdBy.fullName ? createdBy.fullName : createdBy.username ?? i18n.UNKNOWN}
>
);
diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts
index 04bb8801c9f00..ac518a9cc2fb0 100644
--- a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts
+++ b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts
@@ -152,10 +152,6 @@ export const EMAIL_BODY = (caseUrl: string) =>
defaultMessage: 'Case reference: {caseUrl}',
});
-export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', {
- defaultMessage: 'Unknown',
-});
-
export const CHANGED_CONNECTOR_FIELD = i18n.translate(
'xpack.securitySolution.case.caseView.fieldChanged',
{
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
index 0ced285f9dcd9..2abcb70d676ef 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
@@ -131,8 +131,8 @@ export const getUpdateAction = ({
}): EuiCommentProps => ({
username: (
),
type: 'update',
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
index 1967402fd81e0..de3e9c07ae8a3 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
@@ -217,8 +217,8 @@ export const UserActionTree = React.memo(
() => ({
username: (
),
event: i18n.ADDED_DESCRIPTION,
@@ -270,8 +270,8 @@ export const UserActionTree = React.memo(
{
username: (
),
'data-test-subj': `comment-create-action-${comment.id}`,
@@ -418,17 +418,11 @@ export const UserActionTree = React.memo(
const bottomActions = [
{
username: (
-
+
),
'data-test-subj': 'add-comment',
timelineIcon: (
-
+
),
className: 'isEdit',
children: MarkdownNewComment,
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx
index df5c51394b88a..fbebea6f1148f 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx
@@ -22,26 +22,18 @@ describe('UserActionAvatar ', () => {
it('it renders', async () => {
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy();
- expect(
- wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists()
- ).toBeFalsy();
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E');
});
it('it shows the username if the fullName is undefined', async () => {
wrapper = mount();
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy();
- expect(
- wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists()
- ).toBeFalsy();
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e');
});
- it('shows the loading spinner when the username AND the fullName are undefined', async () => {
+ it('shows unknown when the username AND the fullName are undefined', async () => {
wrapper = mount();
- expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy();
- expect(
- wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists()
- ).toBeTruthy();
+ expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy();
+ expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('U');
});
});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx
index 8339d9bedd123..025cbcb2e2710 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx
@@ -5,7 +5,9 @@
*/
import React, { memo } from 'react';
-import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui';
+import { EuiAvatar } from '@elastic/eui';
+
+import * as i18n from './translations';
interface UserActionAvatarProps {
username?: string | null;
@@ -13,17 +15,9 @@ interface UserActionAvatarProps {
}
const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => {
- const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null;
+ const avatarName = fullName && fullName.length > 0 ? fullName : username ?? i18n.UNKNOWN;
- return (
- <>
- {avatarName ? (
-
- ) : (
-
- )}
- >
- );
+ return ;
};
export const UserActionAvatar = memo(UserActionAvatarComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
index dbc153ddbe577..8730de39ba39c 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
@@ -8,19 +8,22 @@ import React, { memo } from 'react';
import { EuiToolTip } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
+import * as i18n from './translations';
+
interface UserActionUsernameProps {
- username: string;
- fullName?: string;
+ username?: string | null;
+ fullName?: string | null;
}
const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => {
+ const tooltipContent = (isEmpty(fullName) ? username : fullName) ?? i18n.UNKNOWN;
return (
{isEmpty(fullName) ? username : fullName}
}
+ content={{tooltipContent}
}
data-test-subj="user-action-username-tooltip"
>
- {username}
+ {username ?? i18n.UNKNOWN.toLowerCase()}
);
};
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
index e2326a3580e6f..9d5ab2d7ae6ef 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
@@ -9,10 +9,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { UserActionUsername } from './user_action_username';
+import * as i18n from './translations';
interface UserActionUsernameWithAvatarProps {
- username: string;
- fullName?: string;
+ username?: string | null;
+ fullName?: string | null;
}
const UserActionUsernameWithAvatarComponent = ({
@@ -29,7 +30,7 @@ const UserActionUsernameWithAvatarComponent = ({
diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx
index 373202968f79b..0d5bf13cd6261 100644
--- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx
@@ -51,7 +51,7 @@ import {
import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
import * as i18n from './translations';
-import { ConnectorTypes } from '../../../../case/common/api/connectors';
+import { ConnectorTypes, CommentType } from '../../../../case/common/api';
const abortCtrl = new AbortController();
const mockKibanaServices = KibanaServices.get as jest.Mock;
@@ -404,6 +404,7 @@ describe('Case Configuration API', () => {
});
const data = {
comment: 'comment',
+ type: CommentType.user,
};
test('check url, method, signal', async () => {
diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts
index 218ed77399df0..c5b60041f5cac 100644
--- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts
+++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts
@@ -17,7 +17,8 @@ import {
CaseUserActionsResponse,
CasesResponse,
CasesFindResponse,
-} from '../../../../case/common/api/cases';
+ CommentType,
+} from '../../../../case/common/api';
import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
import { ConnectorTypes } from '../../../../case/common/api/connectors';
export { connectorsMock } from './configure/mock';
@@ -42,6 +43,7 @@ export const tags: string[] = ['coke', 'pepsi'];
export const basicComment: Comment = {
comment: 'Solve this fast!',
+ type: CommentType.user,
id: basicCommentId,
createdAt: basicCreatedAt,
createdBy: elasticUser,
diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts
index df3e75449b627..c2ddcce8b1d3c 100644
--- a/x-pack/plugins/security_solution/public/cases/containers/types.ts
+++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts
@@ -4,7 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { User, UserActionField, UserAction, CaseConnector } from '../../../../case/common/api';
+import {
+ User,
+ UserActionField,
+ UserAction,
+ CaseConnector,
+ CommentType,
+} from '../../../../case/common/api';
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
@@ -13,6 +19,7 @@ export interface Comment {
createdAt: string;
createdBy: ElasticUser;
comment: string;
+ type: CommentType;
pushedAt: string | null;
pushedBy: string | null;
updatedAt: string | null;
diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx
index d7d9cf9c557c9..773d4b8d1fe56 100644
--- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx
@@ -5,6 +5,8 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
+
+import { CommentType } from '../../../../case/common/api';
import { usePostComment, UsePostComment } from './use_post_comment';
import { basicCaseId } from './mock';
import * as api from './api';
@@ -15,6 +17,7 @@ describe('usePostComment', () => {
const abortCtrl = new AbortController();
const samplePost = {
comment: 'a comment',
+ type: CommentType.user,
};
const updateCaseCallback = jest.fn();
beforeEach(() => {
diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts
index a0b5f71db7df0..1d60310731d5e 100644
--- a/x-pack/plugins/security_solution/public/cases/translations.ts
+++ b/x-pack/plugins/security_solution/public/cases/translations.ts
@@ -234,3 +234,7 @@ export const EDIT_CONNECTOR = i18n.translate('xpack.securitySolution.case.caseVi
export const NO_CONNECTOR = i18n.translate('xpack.securitySolution.case.common.noConnector', {
defaultMessage: 'No connector selected',
});
+
+export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', {
+ defaultMessage: 'Unknown',
+});
diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts
new file mode 100644
index 0000000000000..271b1bfd2e3de
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { ActionTypeModel } from '../../../../../../triggers_actions_ui/public/types';
+import * as i18n from './translations';
+
+export function getActionType(): ActionTypeModel {
+ return {
+ id: '.case',
+ iconClass: 'securityAnalyticsApp',
+ selectMessage: i18n.CASE_CONNECTOR_DESC,
+ actionTypeTitle: i18n.CASE_CONNECTOR_TITLE,
+ validateConnector: () => ({ errors: {} }),
+ validateParams: () => ({ errors: {} }),
+ actionConnectorFields: null,
+ actionParamsFields: null,
+ };
+}
diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts
new file mode 100644
index 0000000000000..a39e04acc1bf3
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const CASE_CONNECTOR_DESC = i18n.translate(
+ 'xpack.securitySolution.case.components.case.selectMessageText',
+ {
+ defaultMessage: 'Create or update a case.',
+ }
+);
+
+export const CASE_CONNECTOR_TITLE = i18n.translate(
+ 'xpack.securitySolution.case.components.case.actionTypeTitle',
+ {
+ defaultMessage: 'Cases',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts
new file mode 100644
index 0000000000000..58d7e89e080e7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { getActionType as getCaseConnectorUI } from './case';
diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx
index b81bea1d12b27..08c780d4a7203 100644
--- a/x-pack/plugins/security_solution/public/plugin.tsx
+++ b/x-pack/plugins/security_solution/public/plugin.tsx
@@ -62,6 +62,7 @@ import {
IndexFieldsStrategyResponse,
} from '../common/search_strategy/index_fields';
import { SecurityAppStore } from './common/store/store';
+import { getCaseConnectorUI } from './common/lib/connectors';
export class Plugin implements IPlugin {
private kibanaVersion: string;
@@ -312,6 +313,8 @@ export class Plugin implements IPlugin {
/**
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
index 51d3b0074ca54..74432157f5659 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
@@ -45,7 +45,7 @@ import { SectionLoading } from '../../components/section_loading';
import { ConnectorAddModal } from './connector_add_modal';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
-import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
+import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants';
import { hasSaveActionsCapability } from '../../lib/capabilities';
interface ActionAccordionFormProps {
@@ -579,6 +579,11 @@ export const ActionForm = ({
const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured);
actionTypeNodes = actionTypeRegistry
.list()
+ /**
+ * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502.
+ * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not.
+ */
+ .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id))
.filter((item) => actionTypesIndex[item.id])
.filter((item) => !!item.actionParamsFields)
.sort((a, b) =>
diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts
index a2a1657a1f4cc..833ed915fad59 100644
--- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts
+++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts
@@ -9,3 +9,5 @@ export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types'
export { builtInGroupByTypes } from './group_by_types';
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';
+// TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502.
+export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case'];
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts
index afae04ae9cf5b..5fb6f21c51c95 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts
@@ -33,11 +33,13 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body: comment } = await supertest
.delete(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`)
.set('kbn-xsrf', 'true')
+ .expect(204)
.send();
expect(comment).to.eql({});
@@ -53,13 +55,15 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body } = await supertest
.delete(`${CASES_URL}/fake-id/comments/${patchedCase.comments[0].id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(404);
+
expect(body.message).to.eql(
`This comment ${patchedCase.comments[0].id} does not exist in fake-id).`
);
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts
index e5c44de90b5a1..c67eda1d3a16b 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts
@@ -29,21 +29,25 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
// post 2 comments
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body: caseComments } = await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/_find`)
.set('kbn-xsrf', 'true')
- .send();
+ .send()
+ .expect(200);
expect(caseComments.comments).to.eql(patchedCase.comments);
});
@@ -54,21 +58,25 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
// post 2 comments
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send({ comment: 'unique' });
+ .send({ comment: 'unique', type: 'user' })
+ .expect(200);
const { body: caseComments } = await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/_find?search=unique`)
.set('kbn-xsrf', 'true')
- .send();
+ .send()
+ .expect(200);
expect(caseComments.comments).to.eql([patchedCase.comments[1]]);
});
@@ -79,10 +87,13 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/_find?perPage=true`)
.set('kbn-xsrf', 'true')
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts
index 53da0ef1d2b16..9c3a85e99c29d 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts
@@ -27,12 +27,14 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body: comment } = await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`)
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts
new file mode 100644
index 0000000000000..a96197cee5f3b
--- /dev/null
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../../../common/ftr_provider_context';
+import { CASES_URL } from '../../../../../../plugins/case/common/constants';
+
+// eslint-disable-next-line import/no-default-export
+export default function createGetTests({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+
+ describe('migrations', () => {
+ before(async () => {
+ await esArchiver.load('cases');
+ });
+
+ after(async () => {
+ await esArchiver.unload('cases');
+ });
+
+ it('7.11.0 migrates cases comments', async () => {
+ const { body: comment } = await supertest
+ .get(
+ `${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/comments/da677740-1ac7-11eb-b5a3-25ee88122510`
+ )
+ .set('kbn-xsrf', 'true')
+ .send();
+
+ expect(comment.type).to.eql('user');
+ });
+ });
+}
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts
index 73aeeb0fb989a..3176841b009d4 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts
@@ -33,7 +33,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
const newComment = 'Well I decided to update my comment. So what? Deal with it.';
const { body } = await supertest
.patch(`${CASES_URL}/${postedCase.id}/comments`)
@@ -42,7 +44,9 @@ export default ({ getService }: FtrProviderContext): void => {
id: patchedCase.comments[0].id,
version: patchedCase.comments[0].version,
comment: newComment,
- });
+ })
+ .expect(200);
+
expect(body.comments[0].comment).to.eql(newComment);
expect(body.updated_by).to.eql(defaultUser);
});
@@ -51,7 +55,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.patch(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
@@ -85,7 +91,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
await supertest
.patch(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
@@ -107,7 +115,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
const newComment = 'Well I decided to update my comment. So what? Deal with it.';
await supertest
.patch(`${CASES_URL}/${postedCase.id}/comments`)
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts
index 6e8353f8ea86a..0c7ab52abf8c8 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts
@@ -33,7 +33,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
expect(patchedCase.comments[0].comment).to.eql(postCommentReq.comment);
expect(patchedCase.updated_by).to.eql(defaultUser);
});
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts
index aa2465e44c5c1..73d17b985216a 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts
@@ -27,7 +27,8 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
const { body } = await supertest
.delete(`${CASES_URL}?ids=["${postedCase.id}"]`)
@@ -42,29 +43,34 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
await supertest
.delete(`${CASES_URL}?ids=["${postedCase.id}"]`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);
+
await supertest
.get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(404);
});
+
it('unhappy path - 404s when case is not there', async () => {
await supertest
.delete(`${CASES_URL}?ids=["fake-id"]`)
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts
index 39762866ac506..17814868fecc0 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts
@@ -33,9 +33,24 @@ export default ({ getService }: FtrProviderContext): void => {
});
it('should return cases', async () => {
- const { body: a } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq);
- const { body: b } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq);
- const { body: c } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq);
+ const { body: a } = await supertest
+ .post(CASES_URL)
+ .set('kbn-xsrf', 'true')
+ .send(postCaseReq)
+ .expect(200);
+
+ const { body: b } = await supertest
+ .post(CASES_URL)
+ .set('kbn-xsrf', 'true')
+ .send(postCaseReq)
+ .expect(200);
+
+ const { body: c } = await supertest
+ .post(CASES_URL)
+ .set('kbn-xsrf', 'true')
+ .send(postCaseReq)
+ .expect(200);
+
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc`)
.set('kbn-xsrf', 'true')
@@ -55,7 +70,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send({ ...postCaseReq, tags: ['unique'] });
+ .send({ ...postCaseReq, tags: ['unique'] })
+ .expect(200);
+
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc&tags=unique`)
.set('kbn-xsrf', 'true')
@@ -74,17 +91,22 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
// post 2 comments
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc`)
.set('kbn-xsrf', 'true')
@@ -110,7 +132,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -124,6 +148,7 @@ export default ({ getService }: FtrProviderContext): void => {
],
})
.expect(200);
+
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc`)
.set('kbn-xsrf', 'true')
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts
index 861a1ce78cf7c..08e80bef34555 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts
@@ -118,6 +118,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -139,6 +140,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -160,6 +162,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -181,6 +184,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts
index 0d3d3df5bbd17..80cf2c8199807 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts
@@ -130,7 +130,8 @@ export default ({ getService }: FtrProviderContext): void => {
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body } = await supertest
.post(`${CASES_URL}/${postedCase.id}/_push`)
@@ -143,6 +144,7 @@ export default ({ getService }: FtrProviderContext): void => {
external_url: 'external_url',
})
.expect(200);
+
expect(body.comments[0].pushed_by).to.eql(defaultUser);
});
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts
index 737f90abf512b..d3cd69384b93d 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts
@@ -26,7 +26,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts
index 515cb72424e2a..71e370809c3c7 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts
@@ -26,7 +26,8 @@ export default ({ getService }: FtrProviderContext): void => {
await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send({ ...postCaseReq, tags: ['unique'] });
+ .send({ ...postCaseReq, tags: ['unique'] })
+ .expect(200);
const { body } = await supertest
.get(CASE_TAGS_URL)
diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts
index e53013348c66b..92ef544ee9b37 100644
--- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts
+++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts
@@ -39,13 +39,15 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
const { body } = await supertest
.get(`${CASES_URL}/${postedCase.id}/user_actions`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(1);
expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title', 'connector']);
@@ -58,7 +60,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -78,6 +82,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['status']);
expect(body[1].action).to.eql('update');
@@ -89,7 +94,8 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
const newConnector = {
id: '123',
@@ -117,6 +123,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['connector']);
expect(body[1].action).to.eql('update');
@@ -130,7 +137,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
@@ -150,6 +159,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(3);
expect(body[1].action_field).to.eql(['tags']);
expect(body[1].action).to.eql('add');
@@ -165,7 +175,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
const newTitle = 'Such a great title';
await supertest
.patch(CASES_URL)
@@ -186,6 +198,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['title']);
expect(body[1].action).to.eql('update');
@@ -197,7 +210,9 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
const newDesc = 'Such a great description';
await supertest
.patch(CASES_URL)
@@ -218,6 +233,7 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
+
expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['description']);
expect(body[1].action).to.eql('update');
@@ -229,19 +245,22 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
const { body } = await supertest
.get(`${CASES_URL}/${postedCase.id}/user_actions`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
- expect(body.length).to.eql(2);
+ expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['comment']);
expect(body[1].action).to.eql('create');
expect(body[1].old_value).to.eql(null);
@@ -252,11 +271,15 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
- .send(postCaseReq);
+ .send(postCaseReq)
+ .expect(200);
+
const { body: patchedCase } = await supertest
.post(`${CASES_URL}/${postedCase.id}/comments`)
.set('kbn-xsrf', 'true')
- .send(postCommentReq);
+ .send(postCommentReq)
+ .expect(200);
+
const newComment = 'Well I decided to update my comment. So what? Deal with it.';
await supertest.patch(`${CASES_URL}/${postedCase.id}/comments`).set('kbn-xsrf', 'true').send({
id: patchedCase.comments[0].id,
@@ -269,8 +292,8 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
- expect(body.length).to.eql(3);
+ expect(body.length).to.eql(3);
expect(body[2].action_field).to.eql(['comment']);
expect(body[2].action).to.eql('update');
expect(body[2].old_value).to.eql(postCommentReq.comment);
@@ -329,8 +352,8 @@ export default ({ getService }: FtrProviderContext): void => {
.set('kbn-xsrf', 'true')
.send()
.expect(200);
- expect(body.length).to.eql(2);
+ expect(body.length).to.eql(2);
expect(body[1].action_field).to.eql(['pushed']);
expect(body[1].action).to.eql('push-to-service');
expect(body[1].old_value).to.eql(null);
diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts
new file mode 100644
index 0000000000000..7a351d09b5b9f
--- /dev/null
+++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts
@@ -0,0 +1,763 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../../common/ftr_provider_context';
+
+import { CASES_URL } from '../../../../../plugins/case/common/constants';
+import {
+ postCaseReq,
+ postCaseResp,
+ removeServerGeneratedPropertiesFromCase,
+ removeServerGeneratedPropertiesFromComments,
+} from '../../../common/lib/mock';
+
+// eslint-disable-next-line import/no-default-export
+export default ({ getService }: FtrProviderContext): void => {
+ const supertest = getService('supertest');
+
+ describe('case_connector', () => {
+ let createdActionId = '';
+
+ it('should return 200 when creating a case action successfully', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+
+ expect(createdAction).to.eql({
+ id: createdActionId,
+ isPreconfigured: false,
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ });
+
+ const { body: fetchedAction } = await supertest
+ .get(`/api/actions/action/${createdActionId}`)
+ .expect(200);
+
+ expect(fetchedAction).to.eql({
+ id: fetchedAction.id,
+ isPreconfigured: false,
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ });
+ });
+
+ describe('create', () => {
+ it('should respond with a 400 Bad Request when creating a case without title', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ tags: ['case', 'connector'],
+ description: 'case description',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.title]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating a case without description', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.description]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating a case without tags', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ description: 'case description',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.tags]: expected value of type [array] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating a case without connector', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ description: 'case description',
+ tags: ['case', 'connector'],
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.id]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating jira without issueType', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ description: 'case description',
+ tags: ['case', 'connector'],
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.fields.issueType]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating a connector with wrong fields', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ description: 'case description',
+ tags: ['case', 'connector'],
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: 'Medium',
+ severity: 'Medium',
+ notExists: 'not-exists',
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.fields.notExists]: definition for this key is missing\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when creating a none without fields as null', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ description: 'case description',
+ tags: ['case', 'connector'],
+ connector: {
+ id: 'none',
+ name: 'None',
+ type: '.none',
+ fields: {},
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subActionParams.connector]: Fields must be set to null for connectors of type .none\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should create a case', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'case description',
+ connector: {
+ id: 'jira',
+ name: 'Jira',
+ type: '.jira',
+ fields: {
+ issueType: '10006',
+ priority: 'High',
+ parent: null,
+ },
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ const { body } = await supertest
+ .get(`${CASES_URL}/${caseConnector.body.data.id}`)
+ .set('kbn-xsrf', 'true')
+ .send()
+ .expect(200);
+
+ const data = removeServerGeneratedPropertiesFromCase(body);
+ expect(data).to.eql({
+ ...postCaseResp(caseConnector.body.data.id),
+ ...params.subActionParams,
+ created_by: {
+ email: null,
+ full_name: null,
+ username: null,
+ },
+ });
+ });
+
+ it('should create a case with connector with field as null if not provided', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'create',
+ subActionParams: {
+ title: 'Case from case connector!!',
+ tags: ['case', 'connector'],
+ description: 'case description',
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {},
+ },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ const { body } = await supertest
+ .get(`${CASES_URL}/${caseConnector.body.data.id}`)
+ .set('kbn-xsrf', 'true')
+ .send()
+ .expect(200);
+
+ const data = removeServerGeneratedPropertiesFromCase(body);
+ expect(data).to.eql({
+ ...postCaseResp(caseConnector.body.data.id),
+ ...params.subActionParams,
+ connector: {
+ id: 'servicenow',
+ name: 'Servicenow',
+ type: '.servicenow',
+ fields: {
+ impact: null,
+ severity: null,
+ urgency: null,
+ },
+ },
+ created_by: {
+ email: null,
+ full_name: null,
+ username: null,
+ },
+ });
+ });
+ });
+
+ describe('update', () => {
+ it('should respond with a 400 Bad Request when updating a case without id', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ version: '123',
+ title: 'Case from case connector!!',
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when updating a case without version', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ id: '123',
+ title: 'Case from case connector!!',
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.version]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should update a case', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+
+ const caseRes = await supertest
+ .post(CASES_URL)
+ .set('kbn-xsrf', 'true')
+ .send(postCaseReq)
+ .expect(200);
+
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ id: caseRes.body.id,
+ version: caseRes.body.version,
+ title: 'Case from case connector!!',
+ },
+ };
+
+ await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ const { body } = await supertest
+ .get(`${CASES_URL}/${caseRes.body.id}`)
+ .set('kbn-xsrf', 'true')
+ .send()
+ .expect(200);
+
+ const data = removeServerGeneratedPropertiesFromCase(body);
+ expect(data).to.eql({
+ ...postCaseResp(caseRes.body.id),
+ title: 'Case from case connector!!',
+ updated_by: {
+ email: null,
+ full_name: null,
+ username: null,
+ },
+ });
+ });
+ });
+
+ describe('addComment', () => {
+ it('should respond with a 400 Bad Request when adding a comment to a case without caseId', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ comment: { comment: 'a comment', type: 'user' },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when adding a comment to a case without comment', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ caseId: '123',
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should respond with a 400 Bad Request when adding a comment to a case without comment type', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+ const params = {
+ subAction: 'update',
+ subActionParams: {
+ caseId: '123',
+ comment: { comment: 'a comment' },
+ },
+ };
+
+ const caseConnector = await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ expect(caseConnector.body).to.eql({
+ status: 'error',
+ actionId: createdActionId,
+ message:
+ 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]',
+ retry: false,
+ });
+ });
+
+ it('should add a comment', async () => {
+ const { body: createdAction } = await supertest
+ .post('/api/actions/action')
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'A case connector',
+ actionTypeId: '.case',
+ config: {},
+ })
+ .expect(200);
+
+ createdActionId = createdAction.id;
+
+ const caseRes = await supertest
+ .post(CASES_URL)
+ .set('kbn-xsrf', 'true')
+ .send(postCaseReq)
+ .expect(200);
+
+ const params = {
+ subAction: 'addComment',
+ subActionParams: {
+ caseId: caseRes.body.id,
+ comment: { comment: 'a comment', type: 'user' },
+ },
+ };
+
+ await supertest
+ .post(`/api/actions/action/${createdActionId}/_execute`)
+ .set('kbn-xsrf', 'foo')
+ .send({ params })
+ .expect(200);
+
+ const { body } = await supertest
+ .get(`${CASES_URL}/${caseRes.body.id}`)
+ .set('kbn-xsrf', 'true')
+ .send()
+ .expect(200);
+
+ const data = removeServerGeneratedPropertiesFromCase(body);
+ const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []);
+ expect({ ...data, comments }).to.eql({
+ ...postCaseResp(caseRes.body.id),
+ comments,
+ totalComment: 1,
+ updated_by: {
+ email: null,
+ full_name: null,
+ username: null,
+ },
+ });
+ });
+ });
+ });
+};
diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts
index aaf2338cde2f0..2f7af95e264f8 100644
--- a/x-pack/test/case_api_integration/basic/tests/index.ts
+++ b/x-pack/test/case_api_integration/basic/tests/index.ts
@@ -31,6 +31,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./configure/get_connectors'));
loadTestFile(require.resolve('./configure/patch_configure'));
loadTestFile(require.resolve('./configure/post_configure'));
+ loadTestFile(require.resolve('./connectors/case'));
// Migrations
loadTestFile(require.resolve('./cases/migrations'));
diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts
index 72d1bc4ec9a37..86d69266c6ec6 100644
--- a/x-pack/test/case_api_integration/common/config.ts
+++ b/x-pack/test/case_api_integration/common/config.ts
@@ -26,6 +26,7 @@ const enabledActionTypes = [
'.servicenow',
'.slack',
'.webhook',
+ '.case',
'test.authorization',
'test.failing',
'test.index-record',
diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts
index 18c57ad3b0b69..d2262c684dc6d 100644
--- a/x-pack/test/case_api_integration/common/lib/mock.ts
+++ b/x-pack/test/case_api_integration/common/lib/mock.ts
@@ -8,6 +8,7 @@ import {
CasePostRequest,
CaseResponse,
CasesFindResponse,
+ CommentResponse,
ConnectorTypes,
} from '../../../../plugins/case/common/api';
export const defaultUser = { email: null, full_name: null, username: 'elastic' };
@@ -23,12 +24,16 @@ export const postCaseReq: CasePostRequest = {
},
};
-export const postCommentReq: { comment: string } = {
+export const postCommentReq: { comment: string; type: string } = {
comment: 'This is a cool comment',
+ type: 'user',
};
-export const postCaseResp = (id: string): Partial => ({
- ...postCaseReq,
+export const postCaseResp = (
+ id: string,
+ req: CasePostRequest = postCaseReq
+): Partial => ({
+ ...req,
id,
comments: [],
totalComment: 0,
@@ -47,6 +52,16 @@ export const removeServerGeneratedPropertiesFromCase = (
return rest;
};
+export const removeServerGeneratedPropertiesFromComments = (
+ comments: CommentResponse[]
+): Array> => {
+ return comments.map((comment) => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const { created_at, updated_at, version, ...rest } = comment;
+ return rest;
+ });
+};
+
export const findCasesResp: CasesFindResponse = {
page: 1,
per_page: 20,
diff --git a/x-pack/test/functional/es_archives/cases/data.json b/x-pack/test/functional/es_archives/cases/data.json
index 2ca805259e318..9af1ac47b61a7 100644
--- a/x-pack/test/functional/es_archives/cases/data.json
+++ b/x-pack/test/functional/es_archives/cases/data.json
@@ -137,3 +137,75 @@
"type": "_doc"
}
}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "cases-comments:da677740-1ac7-11eb-b5a3-25ee88122510",
+ "index": ".kibana_1",
+ "source": {
+ "cases-comments": {
+ "comment": "This is a cool comment",
+ "created_at": "2020-10-30T15:52:02.984Z",
+ "created_by": {
+ "email": null,
+ "full_name": null,
+ "username": "elastic"
+ },
+ "pushed_at": null,
+ "pushed_by": null,
+ "updated_at": null,
+ "updated_by": null
+ },
+ "references": [
+ {
+ "id": "e1900ac0-017f-11eb-93f8-d161651bf509",
+ "name": "associated-cases",
+ "type": "cases"
+ }
+ ],
+ "type": "cases-comments",
+ "updated_at": "2020-10-30T15:52:02.996Z"
+ },
+ "type": "_doc"
+ }
+}
+
+{
+ "type": "doc",
+ "value": {
+ "id": "cases-user-actions:db027ec0-1ac7-11eb-b5a3-25ee88122510",
+ "index": ".kibana_1",
+ "source": {
+ "cases-user-actions": {
+ "action": "create",
+ "action_at": "2020-10-30T15:52:02.984Z",
+ "action_by": {
+ "email": null,
+ "full_name": null,
+ "username": "elastic"
+ },
+ "action_field": [
+ "comment"
+ ],
+ "new_value": "This is a cool comment",
+ "old_value": null
+ },
+ "references": [
+ {
+ "id": "e1900ac0-017f-11eb-93f8-d161651bf509",
+ "name": "associated-cases",
+ "type": "cases"
+ },
+ {
+ "id": "da677740-1ac7-11eb-b5a3-25ee88122510",
+ "name": "associated-cases-comments",
+ "type": "cases-comments"
+ }
+ ],
+ "type": "cases-user-actions",
+ "updated_at": "2020-10-30T15:52:04.012Z"
+ },
+ "type": "_doc"
+ }
+}
From fe3b0538ffdeaea1b8468d2532f368e297756f38 Mon Sep 17 00:00:00 2001
From: Joe Reuter
Date: Wed, 4 Nov 2020 11:27:52 +0100
Subject: [PATCH 03/69] [Lens] Categorical color palettes (#75309)
---
src/plugins/charts/common/constants.ts | 33 +++
src/plugins/charts/common/index.ts | 2 +
src/plugins/charts/common/palette.test.ts | 102 +++++++
src/plugins/charts/common/palette.ts | 160 +++++++++++
src/plugins/charts/kibana.json | 1 +
src/plugins/charts/public/index.ts | 11 +-
src/plugins/charts/public/mocks.ts | 10 +-
src/plugins/charts/public/plugin.ts | 34 ++-
src/plugins/charts/public/services/index.ts | 2 +-
.../{colors => legacy_colors}/colors.test.ts | 8 +-
.../{colors => legacy_colors}/colors.ts | 6 +-
.../colors_palette.test.ts | 4 +-
.../public/services/legacy_colors/index.ts | 20 ++
.../{colors => legacy_colors}/mock.ts | 10 +-
.../{colors => mapped_colors}/index.ts | 2 +-
.../mapped_colors.test.ts | 2 +-
.../mapped_colors.ts | 13 +-
.../services/palettes/lighten_color.test.ts | 44 +++
.../public/services/palettes/lighten_color.ts | 37 +++
.../charts/public/services/palettes/mock.ts | 58 ++++
.../services/palettes/palettes.test.tsx | 261 ++++++++++++++++++
.../public/services/palettes/palettes.tsx | 240 ++++++++++++++++
.../public/services/palettes/service.ts | 56 ++++
.../charts/public/services/palettes/types.ts | 118 ++++++++
.../colors/color_palette.ts | 0
.../charts/public/static/colors/index.ts | 21 ++
.../colors/seed_colors.test.ts | 0
.../colors/seed_colors.ts | 0
src/plugins/charts/public/static/index.ts | 1 +
src/plugins/charts/server/index.ts | 7 +
src/plugins/charts/server/plugin.ts | 11 +-
.../vis_type_tagcloud/public/plugin.ts | 4 +-
.../visualizations/views/timeseries/index.js | 2 +-
.../vis_type_vislib/public/vislib/vis.js | 2 +-
.../__tests__/fixtures/function_specs.ts | 5 +-
.../__tests__/fixtures/test_pointseries.js | 2 +
.../common/__tests__/fixtures/test_styles.js | 14 +-
.../functions/common/index.ts | 6 -
.../functions/common/palette.test.js | 64 -----
.../functions/common/palette.ts | 63 -----
.../canvas_plugin_src/functions/common/pie.ts | 192 -------------
.../functions/common/plot/index.ts | 172 ------------
.../functions/external/saved_lens.ts | 9 +
.../canvas/canvas_plugin_src/plugin.ts | 2 +
.../renderers/embeddable/embeddable.tsx | 8 +-
.../embeddable_input_to_expression.test.ts | 7 +-
.../embeddable_input_to_expression.ts | 6 +-
.../input_type_to_expression/lens.test.ts | 7 +-
.../input_type_to_expression/lens.ts | 12 +-
.../canvas_plugin_src/renderers/pie/index.tsx | 2 +-
.../common/lib/get_colors_from_palette.js | 10 -
.../lib/get_colors_from_palette.test.js | 40 ---
x-pack/plugins/canvas/common/lib/index.ts | 2 -
.../canvas/i18n/functions/dict/palette.ts | 36 ---
.../plugins/canvas/i18n/functions/dict/pie.ts | 7 +-
.../canvas/i18n/functions/dict/plot.ts | 7 +-
.../canvas/i18n/functions/dict/saved_lens.ts | 3 +
.../canvas/i18n/functions/function_help.ts | 13 +-
x-pack/plugins/canvas/kibana.json | 2 +-
x-pack/plugins/canvas/public/application.tsx | 1 +
.../plugins/canvas/public/functions/index.ts | 6 +
.../common => public/functions}/pie.test.js | 33 ++-
x-pack/plugins/canvas/public/functions/pie.ts | 206 ++++++++++++++
.../common => public/functions}/plot.test.js | 36 ++-
.../plot}/get_flot_axis_config.test.js | 8 +-
.../functions}/plot/get_flot_axis_config.ts | 4 +-
.../functions/plot}/get_font_spec.test.js | 4 +-
.../functions}/plot/get_font_spec.ts | 4 +-
.../functions/plot}/get_tick_hash.test.js | 2 +-
.../functions}/plot/get_tick_hash.ts | 2 +-
.../canvas/public/functions/plot/index.ts | 177 ++++++++++++
.../plot}/series_style_to_flot.test.js | 2 +-
.../functions}/plot/series_style_to_flot.ts | 2 +-
x-pack/plugins/canvas/public/plugin.tsx | 3 +
x-pack/plugins/lens/kibana.json | 1 +
.../visualization.test.tsx | 3 +-
.../editor_frame/editor_frame.test.tsx | 8 +-
.../editor_frame/editor_frame.tsx | 4 +
.../editor_frame/save.test.ts | 3 +-
.../editor_frame/state_management.test.ts | 3 +
.../editor_frame/suggestion_helpers.test.ts | 59 ++++
.../editor_frame/suggestion_helpers.ts | 17 +-
.../workspace_panel/chart_switch.test.tsx | 34 +++
.../workspace_panel/chart_switch.tsx | 17 +-
.../workspace_panel/workspace_panel.tsx | 7 +
.../embeddable/embeddable.tsx | 7 +-
.../embeddable/expression_wrapper.tsx | 3 +
.../public/editor_frame_service/mocks.tsx | 29 ++
.../public/editor_frame_service/service.tsx | 6 +
.../public/persistence/saved_object_store.ts | 4 +
.../public/pie_visualization/expression.tsx | 9 +-
.../lens/public/pie_visualization/index.ts | 6 +-
.../render_function.test.tsx | 87 +++++-
.../pie_visualization/render_function.tsx | 72 +++--
.../pie_visualization/suggestions.test.ts | 36 ++-
.../public/pie_visualization/suggestions.ts | 3 +
.../public/pie_visualization/to_expression.ts | 36 ++-
.../lens/public/pie_visualization/toolbar.tsx | 17 +-
.../lens/public/pie_visualization/types.ts | 3 +
.../pie_visualization/visualization.tsx | 31 ++-
x-pack/plugins/lens/public/plugin.ts | 4 +-
.../lens/public/shared_components/index.ts | 1 +
.../shared_components/palette_picker.tsx | 76 +++++
x-pack/plugins/lens/public/types.ts | 11 +-
.../axes_configuration.test.ts | 1 +
.../xy_visualization/color_assignment.test.ts | 182 ++++++++++++
.../xy_visualization/color_assignment.ts | 71 +++++
.../xy_visualization/expression.test.tsx | 98 ++++++-
.../public/xy_visualization/expression.tsx | 42 ++-
.../lens/public/xy_visualization/index.ts | 6 +-
.../xy_visualization/to_expression.test.ts | 6 +-
.../public/xy_visualization/to_expression.ts | 57 +++-
.../lens/public/xy_visualization/types.ts | 9 +
.../xy_visualization/visualization.test.ts | 7 +-
.../public/xy_visualization/visualization.tsx | 22 +-
.../xy_visualization/xy_config_panel.tsx | 17 +-
.../xy_visualization/xy_suggestions.test.ts | 40 ++-
.../public/xy_visualization/xy_suggestions.ts | 17 +-
.../translations/translations/ja-JP.json | 4 -
.../translations/translations/zh-CN.json | 4 -
x-pack/test/functional/apps/lens/colors.ts | 57 ++++
x-pack/test/functional/apps/lens/index.ts | 1 +
.../test/functional/page_objects/lens_page.ts | 17 ++
123 files changed, 2942 insertions(+), 776 deletions(-)
create mode 100644 src/plugins/charts/common/constants.ts
create mode 100644 src/plugins/charts/common/palette.test.ts
create mode 100644 src/plugins/charts/common/palette.ts
rename src/plugins/charts/public/services/{colors => legacy_colors}/colors.test.ts (95%)
rename src/plugins/charts/public/services/{colors => legacy_colors}/colors.ts (94%)
rename src/plugins/charts/public/services/{colors => legacy_colors}/colors_palette.test.ts (96%)
create mode 100644 src/plugins/charts/public/services/legacy_colors/index.ts
rename src/plugins/charts/public/services/{colors => legacy_colors}/mock.ts (82%)
rename src/plugins/charts/public/services/{colors => mapped_colors}/index.ts (95%)
rename src/plugins/charts/public/services/{colors => mapped_colors}/mapped_colors.test.ts (99%)
rename src/plugins/charts/public/services/{colors => mapped_colors}/mapped_colors.ts (89%)
create mode 100644 src/plugins/charts/public/services/palettes/lighten_color.test.ts
create mode 100644 src/plugins/charts/public/services/palettes/lighten_color.ts
create mode 100644 src/plugins/charts/public/services/palettes/mock.ts
create mode 100644 src/plugins/charts/public/services/palettes/palettes.test.tsx
create mode 100644 src/plugins/charts/public/services/palettes/palettes.tsx
create mode 100644 src/plugins/charts/public/services/palettes/service.ts
create mode 100644 src/plugins/charts/public/services/palettes/types.ts
rename src/plugins/charts/public/{services => static}/colors/color_palette.ts (100%)
create mode 100644 src/plugins/charts/public/static/colors/index.ts
rename src/plugins/charts/public/{services => static}/colors/seed_colors.test.ts (100%)
rename src/plugins/charts/public/{services => static}/colors/seed_colors.ts (100%)
delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js
delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts
delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts
delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts
delete mode 100644 x-pack/plugins/canvas/common/lib/get_colors_from_palette.js
delete mode 100644 x-pack/plugins/canvas/common/lib/get_colors_from_palette.test.js
delete mode 100644 x-pack/plugins/canvas/i18n/functions/dict/palette.ts
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/pie.test.js (82%)
create mode 100644 x-pack/plugins/canvas/public/functions/pie.ts
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/plot.test.js (89%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions/plot}/get_flot_axis_config.test.js (94%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/plot/get_flot_axis_config.ts (92%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions/plot}/get_font_spec.test.js (81%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/plot/get_font_spec.ts (92%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions/plot}/get_tick_hash.test.js (96%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/plot/get_tick_hash.ts (98%)
create mode 100644 x-pack/plugins/canvas/public/functions/plot/index.ts
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions/plot}/series_style_to_flot.test.js (98%)
rename x-pack/plugins/canvas/{canvas_plugin_src/functions/common => public/functions}/plot/series_style_to_flot.ts (96%)
create mode 100644 x-pack/plugins/lens/public/shared_components/palette_picker.tsx
create mode 100644 x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts
create mode 100644 x-pack/plugins/lens/public/xy_visualization/color_assignment.ts
create mode 100644 x-pack/test/functional/apps/lens/colors.ts
diff --git a/src/plugins/charts/common/constants.ts b/src/plugins/charts/common/constants.ts
new file mode 100644
index 0000000000000..a36877408d46f
--- /dev/null
+++ b/src/plugins/charts/common/constants.ts
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+// Currently supported palettes. This list might be extended dynamically in a later release
+export const paletteIds = [
+ 'default',
+ 'kibana_palette',
+ 'custom',
+ 'status',
+ 'temperature',
+ 'complimentary',
+ 'negative',
+ 'positive',
+ 'cool',
+ 'warm',
+ 'gray',
+];
diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts
index 1ebf3bcb8f4b6..2582851cb0bc7 100644
--- a/src/plugins/charts/common/index.ts
+++ b/src/plugins/charts/common/index.ts
@@ -18,3 +18,5 @@
*/
export const COLOR_MAPPING_SETTING = 'visualization:colorMapping';
+export * from './palette';
+export * from './constants';
diff --git a/src/plugins/charts/common/palette.test.ts b/src/plugins/charts/common/palette.test.ts
new file mode 100644
index 0000000000000..6081a396f8bf9
--- /dev/null
+++ b/src/plugins/charts/common/palette.test.ts
@@ -0,0 +1,102 @@
+/*
+ * 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 {
+ palette,
+ defaultCustomColors,
+ systemPalette,
+ PaletteOutput,
+ CustomPaletteState,
+} from './palette';
+import { functionWrapper } from 'src/plugins/expressions/common/expression_functions/specs/tests/utils';
+
+describe('palette', () => {
+ const fn = functionWrapper(palette()) as (
+ context: null,
+ args?: { color?: string[]; gradient?: boolean; reverse?: boolean }
+ ) => PaletteOutput;
+
+ it('results a palette', () => {
+ const result = fn(null);
+ expect(result).toHaveProperty('type', 'palette');
+ });
+
+ describe('args', () => {
+ describe('color', () => {
+ it('sets colors', () => {
+ const result = fn(null, { color: ['red', 'green', 'blue'] });
+ expect(result.params!.colors).toEqual(['red', 'green', 'blue']);
+ });
+
+ it('defaults to pault_tor_14 colors', () => {
+ const result = fn(null);
+ expect(result.params!.colors).toEqual(defaultCustomColors);
+ });
+ });
+
+ describe('gradient', () => {
+ it('sets gradient', () => {
+ let result = fn(null, { gradient: true });
+ expect(result.params).toHaveProperty('gradient', true);
+
+ result = fn(null, { gradient: false });
+ expect(result.params).toHaveProperty('gradient', false);
+ });
+
+ it('defaults to false', () => {
+ const result = fn(null);
+ expect(result.params).toHaveProperty('gradient', false);
+ });
+ });
+
+ describe('reverse', () => {
+ it('reverses order of the colors', () => {
+ const result = fn(null, { reverse: true });
+ expect(result.params!.colors).toEqual(defaultCustomColors.reverse());
+ });
+
+ it('keeps the original order of the colors', () => {
+ const result = fn(null, { reverse: false });
+ expect(result.params!.colors).toEqual(defaultCustomColors);
+ });
+
+ it(`defaults to 'false`, () => {
+ const result = fn(null);
+ expect(result.params!.colors).toEqual(defaultCustomColors);
+ });
+ });
+ });
+});
+
+describe('system_palette', () => {
+ const fn = functionWrapper(systemPalette()) as (
+ context: null,
+ args: { name: string; params?: unknown }
+ ) => PaletteOutput;
+
+ it('results a palette', () => {
+ const result = fn(null, { name: 'test' });
+ expect(result).toHaveProperty('type', 'palette');
+ });
+
+ it('returns the name', () => {
+ const result = fn(null, { name: 'test' });
+ expect(result).toHaveProperty('name', 'test');
+ });
+});
diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts
new file mode 100644
index 0000000000000..1cf2af6946c7d
--- /dev/null
+++ b/src/plugins/charts/common/palette.ts
@@ -0,0 +1,160 @@
+/*
+ * 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
+import { i18n } from '@kbn/i18n';
+import { paletteIds } from './constants';
+
+export interface CustomPaletteArguments {
+ color?: string[];
+ gradient: boolean;
+ reverse?: boolean;
+}
+
+export interface CustomPaletteState {
+ colors: string[];
+ gradient: boolean;
+}
+
+export interface SystemPaletteArguments {
+ name: string;
+}
+
+export interface PaletteOutput {
+ type: 'palette';
+ name: string;
+ params?: T;
+}
+export const defaultCustomColors = [
+ // This set of defaults originated in Canvas, which, at present, is the primary
+ // consumer of this function. Changing this default requires a change in Canvas
+ // logic, which would likely be a breaking change in 7.x.
+ '#882E72',
+ '#B178A6',
+ '#D6C1DE',
+ '#1965B0',
+ '#5289C7',
+ '#7BAFDE',
+ '#4EB265',
+ '#90C987',
+ '#CAE0AB',
+ '#F7EE55',
+ '#F6C141',
+ '#F1932D',
+ '#E8601C',
+ '#DC050C',
+];
+
+export function palette(): ExpressionFunctionDefinition<
+ 'palette',
+ null,
+ CustomPaletteArguments,
+ PaletteOutput
+> {
+ return {
+ name: 'palette',
+ aliases: [],
+ type: 'palette',
+ inputTypes: ['null'],
+ help: i18n.translate('charts.functions.paletteHelpText', {
+ defaultMessage: 'Creates a color palette.',
+ }),
+ args: {
+ color: {
+ aliases: ['_'],
+ multi: true,
+ types: ['string'],
+ help: i18n.translate('charts.functions.palette.args.colorHelpText', {
+ defaultMessage:
+ 'The palette colors. Accepts an {html} color name, {hex}, {hsl}, {hsla}, {rgb}, or {rgba}.',
+ values: {
+ html: 'HTML',
+ rgb: 'RGB',
+ rgba: 'RGBA',
+ hex: 'HEX',
+ hsl: 'HSL',
+ hsla: 'HSLA',
+ },
+ }),
+ required: false,
+ },
+ gradient: {
+ types: ['boolean'],
+ default: false,
+ help: i18n.translate('charts.functions.palette.args.gradientHelpText', {
+ defaultMessage: 'Make a gradient palette where supported?',
+ }),
+ options: [true, false],
+ },
+ reverse: {
+ types: ['boolean'],
+ default: false,
+ help: i18n.translate('charts.functions.palette.args.reverseHelpText', {
+ defaultMessage: 'Reverse the palette?',
+ }),
+ options: [true, false],
+ },
+ },
+ fn: (input, args) => {
+ const { color, reverse, gradient } = args;
+ const colors = ([] as string[]).concat(color || defaultCustomColors);
+
+ return {
+ type: 'palette',
+ name: 'custom',
+ params: {
+ colors: reverse ? colors.reverse() : colors,
+ gradient,
+ },
+ };
+ },
+ };
+}
+
+export function systemPalette(): ExpressionFunctionDefinition<
+ 'system_palette',
+ null,
+ SystemPaletteArguments,
+ PaletteOutput
+> {
+ return {
+ name: 'system_palette',
+ aliases: [],
+ type: 'palette',
+ inputTypes: ['null'],
+ help: i18n.translate('charts.functions.systemPaletteHelpText', {
+ defaultMessage: 'Creates a dynamic color palette.',
+ }),
+ args: {
+ name: {
+ types: ['string'],
+ help: i18n.translate('charts.functions.systemPalette.args.nameHelpText', {
+ defaultMessage: 'Name of the palette in the palette list',
+ }),
+ options: paletteIds,
+ },
+ },
+ fn: (input, args) => {
+ return {
+ type: 'palette',
+ name: args.name,
+ };
+ },
+ };
+}
diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json
index 8967e931a0b10..a6d4dbba7238f 100644
--- a/src/plugins/charts/kibana.json
+++ b/src/plugins/charts/kibana.json
@@ -3,5 +3,6 @@
"version": "kibana",
"server": true,
"ui": true,
+ "requiredPlugins": ["expressions"],
"requiredBundles": ["visDefaultEditor"]
}
diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts
index a8203a31a6847..8d7cf79363dae 100644
--- a/src/plugins/charts/public/index.ts
+++ b/src/plugins/charts/public/index.ts
@@ -21,7 +21,14 @@ import { ChartsPlugin } from './plugin';
export const plugin = () => new ChartsPlugin();
-export type ChartsPluginSetup = ReturnType;
-export type ChartsPluginStart = ReturnType;
+export { ChartsPluginSetup, ChartsPluginStart } from './plugin';
export * from './static';
+export * from './services/palettes/types';
+export {
+ PaletteOutput,
+ CustomPaletteArguments,
+ CustomPaletteState,
+ SystemPaletteArguments,
+ paletteIds,
+} from '../common';
diff --git a/src/plugins/charts/public/mocks.ts b/src/plugins/charts/public/mocks.ts
index d8fab7b535e9f..d082c23c28c07 100644
--- a/src/plugins/charts/public/mocks.ts
+++ b/src/plugins/charts/public/mocks.ts
@@ -19,19 +19,22 @@
import { ChartsPlugin } from './plugin';
import { themeServiceMock } from './services/theme/mock';
-import { colorsServiceMock } from './services/colors/mock';
+import { colorsServiceMock } from './services/legacy_colors/mock';
+import { getPaletteRegistry, paletteServiceMock } from './services/palettes/mock';
export type Setup = jest.Mocked>;
export type Start = jest.Mocked>;
const createSetupContract = (): Setup => ({
- colors: colorsServiceMock,
+ legacyColors: colorsServiceMock,
theme: themeServiceMock,
+ palettes: paletteServiceMock.setup({} as any, {} as any),
});
const createStartContract = (): Start => ({
- colors: colorsServiceMock,
+ legacyColors: colorsServiceMock,
theme: themeServiceMock,
+ palettes: paletteServiceMock.setup({} as any, {} as any),
});
export { colorMapsMock } from './static/color_maps/mock';
@@ -39,4 +42,5 @@ export { colorMapsMock } from './static/color_maps/mock';
export const chartPluginMock = {
createSetupContract,
createStartContract,
+ createPaletteRegistry: getPaletteRegistry,
};
diff --git a/src/plugins/charts/public/plugin.ts b/src/plugins/charts/public/plugin.ts
index bc91735f52052..5a28048ced430 100644
--- a/src/plugins/charts/public/plugin.ts
+++ b/src/plugins/charts/public/plugin.ts
@@ -18,16 +18,24 @@
*/
import { Plugin, CoreSetup } from 'kibana/public';
+import { ExpressionsSetup } from '../../expressions/public';
+import { palette, systemPalette } from '../common';
-import { ThemeService, ColorsService } from './services';
+import { ThemeService, LegacyColorsService } from './services';
+import { PaletteService } from './services/palettes/service';
export type Theme = Omit;
-export type Color = Omit;
+export type Color = Omit;
+
+interface SetupDependencies {
+ expressions: ExpressionsSetup;
+}
/** @public */
export interface ChartsPluginSetup {
- colors: Color;
+ legacyColors: Color;
theme: Theme;
+ palettes: ReturnType;
}
/** @public */
@@ -36,22 +44,30 @@ export type ChartsPluginStart = ChartsPluginSetup;
/** @public */
export class ChartsPlugin implements Plugin {
private readonly themeService = new ThemeService();
- private readonly colorsService = new ColorsService();
+ private readonly legacyColorsService = new LegacyColorsService();
+ private readonly paletteService = new PaletteService();
+
+ private palettes: undefined | ReturnType;
- public setup({ uiSettings }: CoreSetup): ChartsPluginSetup {
- this.themeService.init(uiSettings);
- this.colorsService.init(uiSettings);
+ public setup(core: CoreSetup, dependencies: SetupDependencies): ChartsPluginSetup {
+ dependencies.expressions.registerFunction(palette);
+ dependencies.expressions.registerFunction(systemPalette);
+ this.themeService.init(core.uiSettings);
+ this.legacyColorsService.init(core.uiSettings);
+ this.palettes = this.paletteService.setup(core, this.legacyColorsService);
return {
- colors: this.colorsService,
+ legacyColors: this.legacyColorsService,
theme: this.themeService,
+ palettes: this.palettes,
};
}
public start(): ChartsPluginStart {
return {
- colors: this.colorsService,
+ legacyColors: this.legacyColorsService,
theme: this.themeService,
+ palettes: this.palettes!,
};
}
}
diff --git a/src/plugins/charts/public/services/index.ts b/src/plugins/charts/public/services/index.ts
index 2bb4a99494e8a..f590ec9a5ebe6 100644
--- a/src/plugins/charts/public/services/index.ts
+++ b/src/plugins/charts/public/services/index.ts
@@ -17,5 +17,5 @@
* under the License.
*/
-export { ColorsService } from './colors';
+export { LegacyColorsService } from './legacy_colors';
export { ThemeService } from './theme';
diff --git a/src/plugins/charts/public/services/colors/colors.test.ts b/src/plugins/charts/public/services/legacy_colors/colors.test.ts
similarity index 95%
rename from src/plugins/charts/public/services/colors/colors.test.ts
rename to src/plugins/charts/public/services/legacy_colors/colors.test.ts
index a4d7a0781eabd..89cf7a4817377 100644
--- a/src/plugins/charts/public/services/colors/colors.test.ts
+++ b/src/plugins/charts/public/services/legacy_colors/colors.test.ts
@@ -19,14 +19,14 @@
import { coreMock } from '../../../../../core/public/mocks';
import { COLOR_MAPPING_SETTING } from '../../../common';
-import { seedColors } from './seed_colors';
-import { ColorsService } from './colors';
+import { seedColors } from '../../static/colors';
+import { LegacyColorsService } from './colors';
// Local state for config
const config = new Map();
describe('Vislib Color Service', () => {
- const colors = new ColorsService();
+ const colors = new LegacyColorsService();
const mockUiSettings = coreMock.createSetup().uiSettings;
mockUiSettings.get.mockImplementation((a) => config.get(a));
mockUiSettings.set.mockImplementation((...a) => config.set(...a) as any);
@@ -55,7 +55,7 @@ describe('Vislib Color Service', () => {
});
it('should throw error if not initialized', () => {
- const colorsBad = new ColorsService();
+ const colorsBad = new LegacyColorsService();
expect(() => colorsBad.createColorLookupFunction(arr, {})).toThrowError();
});
diff --git a/src/plugins/charts/public/services/colors/colors.ts b/src/plugins/charts/public/services/legacy_colors/colors.ts
similarity index 94%
rename from src/plugins/charts/public/services/colors/colors.ts
rename to src/plugins/charts/public/services/legacy_colors/colors.ts
index 7a1ffc433ee87..e1342a114f8df 100644
--- a/src/plugins/charts/public/services/colors/colors.ts
+++ b/src/plugins/charts/public/services/legacy_colors/colors.ts
@@ -21,8 +21,8 @@ import _ from 'lodash';
import { CoreSetup } from 'kibana/public';
-import { MappedColors } from './mapped_colors';
-import { seedColors } from './seed_colors';
+import { MappedColors } from '../mapped_colors';
+import { seedColors } from '../../static/colors';
/**
* Accepts an array of strings or numbers that are used to create a
@@ -30,7 +30,7 @@ import { seedColors } from './seed_colors';
* Returns a function that accepts a value (i.e. a string or number)
* and returns a hex color associated with that value.
*/
-export class ColorsService {
+export class LegacyColorsService {
private _mappedColors?: MappedColors;
public readonly seedColors = seedColors;
diff --git a/src/plugins/charts/public/services/colors/colors_palette.test.ts b/src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts
similarity index 96%
rename from src/plugins/charts/public/services/colors/colors_palette.test.ts
rename to src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts
index 273a36f6a43a6..f77f6230e43e1 100644
--- a/src/plugins/charts/public/services/colors/colors_palette.test.ts
+++ b/src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts
@@ -17,8 +17,8 @@
* under the License.
*/
-import { seedColors } from './seed_colors';
-import { createColorPalette } from './color_palette';
+import { seedColors } from '../../static/colors';
+import { createColorPalette } from '../../static/colors';
describe('Color Palette', () => {
const num1 = 45;
diff --git a/src/plugins/charts/public/services/legacy_colors/index.ts b/src/plugins/charts/public/services/legacy_colors/index.ts
new file mode 100644
index 0000000000000..278d673f16f13
--- /dev/null
+++ b/src/plugins/charts/public/services/legacy_colors/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 { LegacyColorsService } from './colors';
diff --git a/src/plugins/charts/public/services/colors/mock.ts b/src/plugins/charts/public/services/legacy_colors/mock.ts
similarity index 82%
rename from src/plugins/charts/public/services/colors/mock.ts
rename to src/plugins/charts/public/services/legacy_colors/mock.ts
index f88980e521dda..3c7ff4ebaa2f5 100644
--- a/src/plugins/charts/public/services/colors/mock.ts
+++ b/src/plugins/charts/public/services/legacy_colors/mock.ts
@@ -17,12 +17,16 @@
* under the License.
*/
-import { ColorsService } from './colors';
+import { LegacyColorsService } from './colors';
import { coreMock } from '../../../../../core/public/mocks';
-const colors = new ColorsService();
+const colors = new LegacyColorsService();
colors.init(coreMock.createSetup().uiSettings);
-export const colorsServiceMock: ColorsService = {
+export const colorsServiceMock: LegacyColorsService = {
createColorLookupFunction: jest.fn(colors.createColorLookupFunction.bind(colors)),
+ mappedColors: {
+ mapKeys: jest.fn(),
+ get: jest.fn(),
+ },
} as any;
diff --git a/src/plugins/charts/public/services/colors/index.ts b/src/plugins/charts/public/services/mapped_colors/index.ts
similarity index 95%
rename from src/plugins/charts/public/services/colors/index.ts
rename to src/plugins/charts/public/services/mapped_colors/index.ts
index 7ee5e0262e1b8..31509aef6c535 100644
--- a/src/plugins/charts/public/services/colors/index.ts
+++ b/src/plugins/charts/public/services/mapped_colors/index.ts
@@ -17,4 +17,4 @@
* under the License.
*/
-export { ColorsService } from './colors';
+export * from './mapped_colors';
diff --git a/src/plugins/charts/public/services/colors/mapped_colors.test.ts b/src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts
similarity index 99%
rename from src/plugins/charts/public/services/colors/mapped_colors.test.ts
rename to src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts
index 9d00bf098de4c..dc1f75ef7eb46 100644
--- a/src/plugins/charts/public/services/colors/mapped_colors.test.ts
+++ b/src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts
@@ -22,7 +22,7 @@ import Color from 'color';
import { coreMock } from '../../../../../core/public/mocks';
import { COLOR_MAPPING_SETTING } from '../../../common';
-import { seedColors } from './seed_colors';
+import { seedColors } from '../../static/colors';
import { MappedColors } from './mapped_colors';
// Local state for config
diff --git a/src/plugins/charts/public/services/colors/mapped_colors.ts b/src/plugins/charts/public/services/mapped_colors/mapped_colors.ts
similarity index 89%
rename from src/plugins/charts/public/services/colors/mapped_colors.ts
rename to src/plugins/charts/public/services/mapped_colors/mapped_colors.ts
index 15f9be32b829c..2934d4208d22c 100644
--- a/src/plugins/charts/public/services/colors/mapped_colors.ts
+++ b/src/plugins/charts/public/services/mapped_colors/mapped_colors.ts
@@ -23,7 +23,7 @@ import Color from 'color';
import { CoreSetup } from 'kibana/public';
import { COLOR_MAPPING_SETTING } from '../../../common';
-import { createColorPalette } from './color_palette';
+import { createColorPalette } from '../../static/colors';
const standardizeColor = (color: string) => new Color(color).hex().toLowerCase();
@@ -36,7 +36,10 @@ export class MappedColors {
private _oldMap: any;
private _mapping: any;
- constructor(private uiSettings: CoreSetup['uiSettings']) {
+ constructor(
+ private uiSettings: CoreSetup['uiSettings'],
+ private colorPaletteFn: (num: number) => string[] = createColorPalette
+ ) {
this._oldMap = {};
this._mapping = {};
}
@@ -57,6 +60,10 @@ export class MappedColors {
return this.getConfigColorMapping()[key as any] || this._mapping[key];
}
+ getColorFromConfig(key: string | number) {
+ return this.getConfigColorMapping()[key as any];
+ }
+
flush() {
this._oldMap = _.clone(this._mapping);
this._mapping = {};
@@ -89,7 +96,7 @@ export class MappedColors {
// Generate a color palette big enough that all new keys can have unique color values
const allColors = _(this._mapping).values().union(configColors).union(oldColors).value();
- const colorPalette = createColorPalette(allColors.length + keysToMap.length);
+ const colorPalette = this.colorPaletteFn(allColors.length + keysToMap.length);
let newColors = _.difference(colorPalette, allColors);
while (keysToMap.length > newColors.length) {
diff --git a/src/plugins/charts/public/services/palettes/lighten_color.test.ts b/src/plugins/charts/public/services/palettes/lighten_color.test.ts
new file mode 100644
index 0000000000000..643046ca444b6
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/lighten_color.test.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 color from 'color';
+import { lightenColor } from './lighten_color';
+
+describe('lighten_color', () => {
+ it('should keep existing color if there is a single color step', () => {
+ expect(lightenColor('#FF0000', 1, 1)).toEqual('#FF0000');
+ });
+
+ it('should keep existing color for the first step', () => {
+ expect(lightenColor('#FF0000', 1, 10)).toEqual('#FF0000');
+ });
+
+ it('should lighten color', () => {
+ const baseLightness = color('#FF0000', 'hsl').lightness();
+ const result1 = lightenColor('#FF0000', 5, 10);
+ const result2 = lightenColor('#FF0000', 10, 10);
+ expect(baseLightness).toBeLessThan(color(result1, 'hsl').lightness());
+ expect(color(result1, 'hsl').lightness()).toBeLessThan(color(result2, 'hsl').lightness());
+ });
+
+ it('should not exceed top lightness', () => {
+ const result = lightenColor('#c0c0c0', 10, 10);
+ expect(color(result, 'hsl').lightness()).toBeLessThan(95);
+ });
+});
diff --git a/src/plugins/charts/public/services/palettes/lighten_color.ts b/src/plugins/charts/public/services/palettes/lighten_color.ts
new file mode 100644
index 0000000000000..57ffb05eb5aa7
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/lighten_color.ts
@@ -0,0 +1,37 @@
+/*
+ * 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 color from 'color';
+
+const MAX_LIGHTNESS = 93;
+const MAX_LIGHTNESS_SPACE = 20;
+
+export function lightenColor(baseColor: string, step: number, totalSteps: number) {
+ if (totalSteps === 1) {
+ return baseColor;
+ }
+
+ const hslColor = color(baseColor, 'hsl');
+ const outputColorLightness = hslColor.lightness();
+ const lightnessSpace = Math.min(MAX_LIGHTNESS - outputColorLightness, MAX_LIGHTNESS_SPACE);
+ const currentLevelTargetLightness =
+ outputColorLightness + lightnessSpace * ((step - 1) / (totalSteps - 1));
+ const lightenedColor = hslColor.lightness(currentLevelTargetLightness);
+ return lightenedColor.hex();
+}
diff --git a/src/plugins/charts/public/services/palettes/mock.ts b/src/plugins/charts/public/services/palettes/mock.ts
new file mode 100644
index 0000000000000..a7ec3cc16ce6f
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/mock.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 { PublicMethodsOf } from '@kbn/utility-types';
+import { PaletteService } from './service';
+import { PaletteDefinition, SeriesLayer } from './types';
+
+export const getPaletteRegistry = () => {
+ const mockPalette: jest.Mocked = {
+ id: 'default',
+ title: 'My Palette',
+ getColor: jest.fn((_: SeriesLayer[]) => 'black'),
+ getColors: jest.fn((num: number) => ['red', 'black']),
+ toExpression: jest.fn(() => ({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'system_palette',
+ arguments: {
+ name: ['default'],
+ },
+ },
+ ],
+ })),
+ };
+
+ return {
+ get: (_: string) => mockPalette,
+ getAll: () => [mockPalette],
+ };
+};
+
+export const paletteServiceMock: PublicMethodsOf = {
+ setup() {
+ return {
+ getPalettes: async () => {
+ return getPaletteRegistry();
+ },
+ };
+ },
+};
diff --git a/src/plugins/charts/public/services/palettes/palettes.test.tsx b/src/plugins/charts/public/services/palettes/palettes.test.tsx
new file mode 100644
index 0000000000000..5d9337f1ee683
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/palettes.test.tsx
@@ -0,0 +1,261 @@
+/*
+ * 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 { coreMock } from '../../../../../core/public/mocks';
+import { PaletteDefinition } from './types';
+import { buildPalettes } from './palettes';
+import { colorsServiceMock } from '../legacy_colors/mock';
+
+describe('palettes', () => {
+ const palettes: Record = buildPalettes(
+ coreMock.createStart().uiSettings,
+ colorsServiceMock
+ );
+ describe('default palette', () => {
+ it('should return different colors based on behind text flag', () => {
+ const palette = palettes.default;
+
+ const color1 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ]);
+ const color2 = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ],
+ {
+ behindText: true,
+ }
+ );
+ expect(color1).not.toEqual(color2);
+ });
+
+ it('should return different colors based on rank at current series', () => {
+ const palette = palettes.default;
+
+ const color1 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ]);
+ const color2 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 1,
+ totalSeriesAtDepth: 5,
+ },
+ ]);
+ expect(color1).not.toEqual(color2);
+ });
+
+ it('should return the same color for different positions on outer series layers', () => {
+ const palette = palettes.default;
+
+ const color1 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ {
+ name: 'def',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 2,
+ },
+ ]);
+ const color2 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ {
+ name: 'ghj',
+ rankAtDepth: 1,
+ totalSeriesAtDepth: 1,
+ },
+ ]);
+ expect(color1).toEqual(color2);
+ });
+ });
+
+ describe('gradient palette', () => {
+ const palette = palettes.warm;
+
+ it('should use the whole gradient', () => {
+ const wholePalette = palette.getColors(10);
+ const color1 = palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 10,
+ },
+ ]);
+ const color2 = palette.getColor([
+ {
+ name: 'def',
+ rankAtDepth: 9,
+ totalSeriesAtDepth: 10,
+ },
+ ]);
+ expect(color1).toEqual(wholePalette[0]);
+ expect(color2).toEqual(wholePalette[9]);
+ });
+ });
+
+ describe('legacy palette', () => {
+ const palette = palettes.kibana_palette;
+
+ beforeEach(() => {
+ (colorsServiceMock.mappedColors.mapKeys as jest.Mock).mockClear();
+ (colorsServiceMock.mappedColors.get as jest.Mock).mockClear();
+ });
+
+ it('should query legacy color service', () => {
+ palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 10,
+ },
+ ]);
+ expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledWith(['abc']);
+ expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledWith('abc');
+ });
+
+ it('should always use root series', () => {
+ palette.getColor([
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 10,
+ },
+ {
+ name: 'def',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 10,
+ },
+ ]);
+ expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledTimes(1);
+ expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledWith(['abc']);
+ expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledTimes(1);
+ expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledWith('abc');
+ });
+ });
+
+ describe('custom palette', () => {
+ const palette = palettes.custom;
+ it('should return different colors based on rank at current series', () => {
+ const color1 = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ ],
+ {},
+ {
+ colors: ['#00ff00', '#000000'],
+ }
+ );
+ const color2 = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 1,
+ totalSeriesAtDepth: 5,
+ },
+ ],
+ {},
+ {
+ colors: ['#00ff00', '#000000'],
+ }
+ );
+ expect(color1).not.toEqual(color2);
+ });
+
+ it('should return the same color for different positions on outer series layers', () => {
+ const color1 = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ {
+ name: 'def',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 2,
+ },
+ ],
+ {},
+ {
+ colors: ['#00ff00', '#000000'],
+ }
+ );
+ const color2 = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 5,
+ },
+ {
+ name: 'ghj',
+ rankAtDepth: 1,
+ totalSeriesAtDepth: 1,
+ },
+ ],
+ {},
+ {
+ colors: ['#00ff00', '#000000'],
+ }
+ );
+ expect(color1).toEqual(color2);
+ });
+
+ it('should use passed in colors', () => {
+ const color = palette.getColor(
+ [
+ {
+ name: 'abc',
+ rankAtDepth: 0,
+ totalSeriesAtDepth: 10,
+ },
+ ],
+ {},
+ {
+ colors: ['#00ff00', '#000000'],
+ gradient: true,
+ }
+ );
+ expect(color).toEqual('#00ff00');
+ });
+ });
+});
diff --git a/src/plugins/charts/public/services/palettes/palettes.tsx b/src/plugins/charts/public/services/palettes/palettes.tsx
new file mode 100644
index 0000000000000..c1fd7c3cc739f
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/palettes.tsx
@@ -0,0 +1,240 @@
+/*
+ * 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.
+ */
+
+// @ts-ignore
+import chroma from 'chroma-js';
+import { i18n } from '@kbn/i18n';
+import { IUiSettingsClient } from 'src/core/public';
+import {
+ euiPaletteColorBlind,
+ euiPaletteCool,
+ euiPaletteGray,
+ euiPaletteNegative,
+ euiPalettePositive,
+ euiPaletteWarm,
+ euiPaletteColorBlindBehindText,
+ euiPaletteForStatus,
+ euiPaletteForTemperature,
+ euiPaletteComplimentary,
+} from '@elastic/eui';
+import { ChartsPluginSetup } from '../../../../../../src/plugins/charts/public';
+import { lightenColor } from './lighten_color';
+import { ChartColorConfiguration, PaletteDefinition, SeriesLayer } from './types';
+import { LegacyColorsService } from '../legacy_colors';
+
+function buildRoundRobinCategoricalWithMappedColors(): Omit {
+ const colors = euiPaletteColorBlind({ rotations: 2 });
+ const behindTextColors = euiPaletteColorBlindBehindText({ rotations: 2 });
+ function getColor(
+ series: SeriesLayer[],
+ chartConfiguration: ChartColorConfiguration = { behindText: false }
+ ) {
+ const outputColor = chartConfiguration.behindText
+ ? behindTextColors[series[0].rankAtDepth % behindTextColors.length]
+ : colors[series[0].rankAtDepth % colors.length];
+
+ if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) {
+ return outputColor;
+ }
+
+ return lightenColor(outputColor, series.length, chartConfiguration.maxDepth);
+ }
+ return {
+ id: 'default',
+ getColor,
+ getColors: () => euiPaletteColorBlind(),
+ toExpression: () => ({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'system_palette',
+ arguments: {
+ name: ['default'],
+ },
+ },
+ ],
+ }),
+ };
+}
+
+function buildGradient(
+ id: string,
+ colors: (n: number) => string[]
+): Omit {
+ function getColor(
+ series: SeriesLayer[],
+ chartConfiguration: ChartColorConfiguration = { behindText: false }
+ ) {
+ const totalSeriesAtDepth = series[0].totalSeriesAtDepth;
+ const rankAtDepth = series[0].rankAtDepth;
+ const actualColors = colors(totalSeriesAtDepth);
+ const outputColor = actualColors[rankAtDepth];
+
+ if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) {
+ return outputColor;
+ }
+
+ return lightenColor(outputColor, series.length, chartConfiguration.maxDepth);
+ }
+ return {
+ id,
+ getColor,
+ getColors: colors,
+ toExpression: () => ({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'system_palette',
+ arguments: {
+ name: [id],
+ },
+ },
+ ],
+ }),
+ };
+}
+
+function buildSyncedKibanaPalette(
+ colors: ChartsPluginSetup['legacyColors']
+): Omit {
+ function getColor(series: SeriesLayer[], chartConfiguration: ChartColorConfiguration = {}) {
+ colors.mappedColors.mapKeys([series[0].name]);
+ const outputColor = colors.mappedColors.get(series[0].name);
+
+ if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) {
+ return outputColor;
+ }
+
+ return lightenColor(outputColor, series.length, chartConfiguration.maxDepth);
+ }
+ return {
+ id: 'kibana_palette',
+ getColor,
+ getColors: () => colors.seedColors.slice(0, 10),
+ toExpression: () => ({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'system_palette',
+ arguments: {
+ name: ['kibana_palette'],
+ },
+ },
+ ],
+ }),
+ };
+}
+
+function buildCustomPalette(): PaletteDefinition {
+ return {
+ id: 'custom',
+ getColor: (
+ series: SeriesLayer[],
+ chartConfiguration: ChartColorConfiguration = { behindText: false },
+ { colors, gradient }: { colors: string[]; gradient: boolean }
+ ) => {
+ const actualColors = gradient
+ ? chroma.scale(colors).colors(series[0].totalSeriesAtDepth)
+ : colors;
+ const outputColor = actualColors[series[0].rankAtDepth % actualColors.length];
+
+ if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) {
+ return outputColor;
+ }
+
+ return lightenColor(outputColor, series.length, chartConfiguration.maxDepth);
+ },
+ internal: true,
+ title: i18n.translate('charts.palettes.customLabel', { defaultMessage: 'Custom' }),
+ getColors: (size: number, { colors, gradient }: { colors: string[]; gradient: boolean }) => {
+ return gradient ? chroma.scale(colors).colors(size) : colors;
+ },
+ toExpression: ({ colors, gradient }: { colors: string[]; gradient: boolean }) => ({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'palette',
+ arguments: {
+ color: colors,
+ gradient: [gradient],
+ },
+ },
+ ],
+ }),
+ } as PaletteDefinition;
+}
+
+export const buildPalettes: (
+ uiSettings: IUiSettingsClient,
+ legacyColorsService: LegacyColorsService
+) => Record = (uiSettings, legacyColorsService) => {
+ return {
+ default: {
+ title: i18n.translate('charts.palettes.defaultPaletteLabel', {
+ defaultMessage: 'Default',
+ }),
+ ...buildRoundRobinCategoricalWithMappedColors(),
+ },
+ status: {
+ title: i18n.translate('charts.palettes.statusLabel', { defaultMessage: 'Status' }),
+ ...buildGradient('status', euiPaletteForStatus),
+ },
+ temperature: {
+ title: i18n.translate('charts.palettes.temperatureLabel', { defaultMessage: 'Temperature' }),
+ ...buildGradient('temperature', euiPaletteForTemperature),
+ },
+ complimentary: {
+ title: i18n.translate('charts.palettes.complimentaryLabel', {
+ defaultMessage: 'Complimentary',
+ }),
+ ...buildGradient('complimentary', euiPaletteComplimentary),
+ },
+ negative: {
+ title: i18n.translate('charts.palettes.negativeLabel', { defaultMessage: 'Negative' }),
+ ...buildGradient('negative', euiPaletteNegative),
+ },
+ positive: {
+ title: i18n.translate('charts.palettes.positiveLabel', { defaultMessage: 'Positive' }),
+ ...buildGradient('positive', euiPalettePositive),
+ },
+ cool: {
+ title: i18n.translate('charts.palettes.coolLabel', { defaultMessage: 'Cool' }),
+ ...buildGradient('cool', euiPaletteCool),
+ },
+ warm: {
+ title: i18n.translate('charts.palettes.warmLabel', { defaultMessage: 'Warm' }),
+ ...buildGradient('warm', euiPaletteWarm),
+ },
+ gray: {
+ title: i18n.translate('charts.palettes.grayLabel', { defaultMessage: 'Gray' }),
+ ...buildGradient('gray', euiPaletteGray),
+ },
+ kibana_palette: {
+ title: i18n.translate('charts.palettes.kibanaPaletteLabel', {
+ defaultMessage: 'Compatibility',
+ }),
+ ...buildSyncedKibanaPalette(legacyColorsService),
+ },
+ custom: buildCustomPalette() as PaletteDefinition,
+ };
+};
diff --git a/src/plugins/charts/public/services/palettes/service.ts b/src/plugins/charts/public/services/palettes/service.ts
new file mode 100644
index 0000000000000..5d0bc2c9037b2
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/service.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 { CoreSetup } from 'kibana/public';
+import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public';
+import {
+ ChartsPluginSetup,
+ PaletteDefinition,
+ PaletteRegistry,
+} from '../../../../../../src/plugins/charts/public';
+import { LegacyColorsService } from '../legacy_colors';
+
+export interface PaletteSetupPlugins {
+ expressions: ExpressionsSetup;
+ charts: ChartsPluginSetup;
+}
+
+export class PaletteService {
+ private palettes: Record> | undefined = undefined;
+ constructor() {}
+
+ public setup(core: CoreSetup, colorsService: LegacyColorsService) {
+ return {
+ getPalettes: async (): Promise => {
+ if (!this.palettes) {
+ const { buildPalettes } = await import('./palettes');
+ this.palettes = buildPalettes(core.uiSettings, colorsService);
+ }
+ return {
+ get: (name: string) => {
+ return this.palettes![name];
+ },
+ getAll: () => {
+ return Object.values(this.palettes!);
+ },
+ };
+ },
+ };
+ }
+}
diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts
new file mode 100644
index 0000000000000..f92bcb4bd0824
--- /dev/null
+++ b/src/plugins/charts/public/services/palettes/types.ts
@@ -0,0 +1,118 @@
+/*
+ * 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 { Ast } from '@kbn/interpreter/common';
+
+/**
+ * Information about a series in a chart used to determine its color.
+ * Series layers can be nested, this means each series layer can have an ancestor.
+ */
+export interface SeriesLayer {
+ /**
+ * Name of the series (can be used for lookup-based coloring)
+ */
+ name: string;
+ /**
+ * Rank of the series compared to siblings with the same ancestor
+ */
+ rankAtDepth: number;
+ /**
+ * Total number of series with the same ancestor
+ */
+ totalSeriesAtDepth: number;
+}
+
+/**
+ * Information about the structure of a chart to determine the color of a series within it.
+ */
+export interface ChartColorConfiguration {
+ /**
+ * Overall number of series in the current chart
+ */
+ totalSeries?: number;
+ /**
+ * Max nesting depth of the series tree
+ */
+ maxDepth?: number;
+ /**
+ * Flag whether the color will be used behind text. The palette can use this information to
+ * adjust colors for better a11y. Might be ignored depending on the palette.
+ */
+ behindText?: boolean;
+}
+
+/**
+ * Definition of a global palette.
+ *
+ * A palette controls the appearance of Lens charts on an editor level.
+ * The palette wont get reset when switching charts.
+ *
+ * A palette can hold internal state (e.g. for customizations) and also includes
+ * an editor component to edit the internal state.
+ */
+export interface PaletteDefinition {
+ /**
+ * Unique id of the palette (this will be persisted along with the visualization state)
+ */
+ id: string;
+ /**
+ * User facing title (should be i18n-ized)
+ */
+ title: string;
+ /**
+ * Flag indicating whether users should be able to pick this palette manually.
+ */
+ internal?: boolean;
+ /**
+ * Serialize the internal state of the palette into an expression function.
+ * This function should be used to pass the palette to the expression function applying color and other styles
+ * @param state The internal state of the palette
+ */
+ toExpression: (state?: T) => Ast;
+ /**
+ * Renders the UI for editing the internal state of the palette.
+ * Not each palette has to feature an internal state, so this is an optional property.
+ * @param domElement The dom element to the render the editor UI into
+ * @param props Current state and state setter to issue updates
+ */
+ renderEditor?: (
+ domElement: Element,
+ props: { state?: T; setState: (updater: (oldState: T) => T) => void }
+ ) => void;
+ /**
+ * Color a series according to the internal rules of the palette.
+ * @param series The current series along with its ancestors.
+ * @param state The internal state of the palette
+ */
+ getColor: (
+ series: SeriesLayer[],
+ chartConfiguration?: ChartColorConfiguration,
+ state?: T
+ ) => string | null;
+ /**
+ * Get a spectrum of colors of the current palette.
+ * This can be used if the chart wants to control color assignment locally.
+ */
+ getColors: (size: number, state?: T) => string[];
+}
+
+export interface PaletteRegistry {
+ get: (name: string) => PaletteDefinition;
+ getAll: () => Array>;
+}
diff --git a/src/plugins/charts/public/services/colors/color_palette.ts b/src/plugins/charts/public/static/colors/color_palette.ts
similarity index 100%
rename from src/plugins/charts/public/services/colors/color_palette.ts
rename to src/plugins/charts/public/static/colors/color_palette.ts
diff --git a/src/plugins/charts/public/static/colors/index.ts b/src/plugins/charts/public/static/colors/index.ts
new file mode 100644
index 0000000000000..4970d2202b50e
--- /dev/null
+++ b/src/plugins/charts/public/static/colors/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export * from './color_palette';
+export * from './seed_colors';
diff --git a/src/plugins/charts/public/services/colors/seed_colors.test.ts b/src/plugins/charts/public/static/colors/seed_colors.test.ts
similarity index 100%
rename from src/plugins/charts/public/services/colors/seed_colors.test.ts
rename to src/plugins/charts/public/static/colors/seed_colors.test.ts
diff --git a/src/plugins/charts/public/services/colors/seed_colors.ts b/src/plugins/charts/public/static/colors/seed_colors.ts
similarity index 100%
rename from src/plugins/charts/public/services/colors/seed_colors.ts
rename to src/plugins/charts/public/static/colors/seed_colors.ts
diff --git a/src/plugins/charts/public/static/index.ts b/src/plugins/charts/public/static/index.ts
index 6fc097d05467f..b8a8406c375dd 100644
--- a/src/plugins/charts/public/static/index.ts
+++ b/src/plugins/charts/public/static/index.ts
@@ -18,4 +18,5 @@
*/
export * from './color_maps';
+export * from './colors';
export * from './components';
diff --git a/src/plugins/charts/server/index.ts b/src/plugins/charts/server/index.ts
index 75a57ab6b405c..3e749489d42dd 100644
--- a/src/plugins/charts/server/index.ts
+++ b/src/plugins/charts/server/index.ts
@@ -18,5 +18,12 @@
*/
import { ChartsServerPlugin } from './plugin';
+export {
+ PaletteOutput,
+ CustomPaletteArguments,
+ CustomPaletteState,
+ SystemPaletteArguments,
+ paletteIds,
+} from '../common';
export const plugin = () => new ChartsServerPlugin();
diff --git a/src/plugins/charts/server/plugin.ts b/src/plugins/charts/server/plugin.ts
index 6bf45fb804469..0123459bd25d2 100644
--- a/src/plugins/charts/server/plugin.ts
+++ b/src/plugins/charts/server/plugin.ts
@@ -20,10 +20,17 @@
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { CoreSetup, Plugin } from 'kibana/server';
-import { COLOR_MAPPING_SETTING } from '../common';
+import { COLOR_MAPPING_SETTING, palette, systemPalette } from '../common';
+import { ExpressionsServerSetup } from '../../expressions/server';
+
+interface SetupDependencies {
+ expressions: ExpressionsServerSetup;
+}
export class ChartsServerPlugin implements Plugin