diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index c18777c8b052d..2f8ad9cf0a18b 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1370,6 +1370,41 @@ }, "category": { "type": "keyword" + }, + "customFields": { + "type": "nested", + "properties": { + "key": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "number": { + "type": "long", + "ignore_malformed": true + }, + "boolean": { + "type": "boolean", + "ignore_malformed": true + }, + "string": { + "type": "text" + }, + "date": { + "type": "date", + "ignore_malformed": true + }, + "ip": { + "type": "ip", + "ignore_malformed": true + } + } + } + } } } }, diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index ec5fd0ca2add5..423008ee1eece 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -69,7 +69,7 @@ describe('checking migration metadata changes on all registered SO types', () => "canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8", "canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c", "canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179", - "cases": "b43a8ce985c406167e1d115381805a48cb3b0e61", + "cases": "2392189ed338857d4815a4cef6051f9ad124d39d", "cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25", "cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf", "cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25", diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 01c8543493f7a..603fa70e7d0d4 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -128,6 +128,11 @@ export const MAX_CASES_TO_UPDATE = 100 as const; export const MAX_BULK_CREATE_ATTACHMENTS = 100 as const; export const MAX_USER_ACTIONS_PER_CASE = 10000 as const; export const MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES = 100 as const; +export const MAX_CUSTOM_FIELDS_PER_CASE = 10 as const; +export const MAX_CUSTOM_FIELD_KEY_LENGTH = 36 as const; // uuidv4 length +export const MAX_CUSTOM_FIELD_LABEL_LENGTH = 50 as const; +export const MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH = 160 as const; +export const MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS = 10 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/schema/index.ts b/x-pack/plugins/cases/common/schema/index.ts index bc3e6af9ea584..b38d499c8c04c 100644 --- a/x-pack/plugins/cases/common/schema/index.ts +++ b/x-pack/plugins/cases/common/schema/index.ts @@ -153,3 +153,26 @@ export const limitedNumberSchema = ({ fieldName, min, max }: LimitedSchemaType) }), rt.identity ); + +export interface RegexStringSchemaType { + codec: rt.Type; + pattern: string; + message: string; +} + +export const regexStringRt = ({ codec, pattern, message }: RegexStringSchemaType) => + new rt.Type( + 'RegexString', + codec.is, + (input, context) => + either.chain(codec.validate(input, context), (value) => { + const regex = new RegExp(pattern, 'g'); + + if (!regex.test(value)) { + return rt.failure(input, context, message); + } + + return rt.success(value); + }), + rt.identity + ); diff --git a/x-pack/plugins/cases/common/types/api/case/v1.test.ts b/x-pack/plugins/cases/common/types/api/case/v1.test.ts index 91676b7ca6510..ce27fe5070b2c 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.test.ts @@ -16,12 +16,16 @@ import { MAX_LENGTH_PER_TAG, MAX_TITLE_LENGTH, MAX_CATEGORY_LENGTH, + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, } from '../../../constants'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { AttachmentType } from '../../domain/attachment/v1'; +import type { Case } from '../../domain/case/v1'; import { CaseSeverity, CaseStatuses } from '../../domain/case/v1'; import { ConnectorTypes } from '../../domain/connector/v1'; import { CasesStatusRequestRt, CasesStatusResponseRt } from '../stats/v1'; +import type { CasePostRequest } from './v1'; import { AllReportersFindRequestRt, CasePatchRequestRt, @@ -37,8 +41,9 @@ import { CasesFindResponseRt, CasesPatchRequestRt, } from './v1'; +import { CustomFieldTypes } from '../../domain/custom_field/v1'; -const basicCase = { +const basicCase: Case = { owner: 'cases', closed_at: null, closed_by: null, @@ -96,289 +101,398 @@ const basicCase = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['www.example.com'], + }, + ], }; -describe('Status', () => { - describe('CasesStatusRequestRt', () => { - const defaultRequest = { - from: '2022-04-28T15:18:00.000Z', - to: '2022-04-28T15:22:00.000Z', - owner: 'cases', - }; +describe('CasePostRequestRt', () => { + const defaultRequest: CasePostRequest = { + description: 'A description', + tags: ['new', 'case'], + title: 'My new case', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + settings: { + syncAlerts: true, + }, + owner: 'cases', + severity: CaseSeverity.LOW, + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }; - it('has expected attributes in request', () => { - const query = CasesStatusRequestRt.decode(defaultRequest); + it('has expected attributes in request', () => { + const query = CasePostRequestRt.decode(defaultRequest); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); + }); - it('has removes foo:bar attributes from request', () => { - const query = CasesStatusRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('removes foo:bar attributes from request', () => { + const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); }); - describe('CasesStatusResponseRt', () => { - const defaultResponse = { - count_closed_cases: 1, - count_in_progress_cases: 2, - count_open_cases: 1, - }; - - it('has expected attributes in response', () => { - const query = CasesStatusResponseRt.decode(defaultResponse); + it('removes foo:bar attributes from connector', () => { + const query = CasePostRequestRt.decode({ + ...defaultRequest, + connector: { ...defaultRequest.connector, foo: 'bar' }, + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultResponse, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); + }); - it('removes foo:bar attributes from response', () => { - const query = CasesStatusResponseRt.decode({ ...defaultResponse, foo: 'bar' }); + it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { + const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultResponse, - }); - }); + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees })) + ).toContain('The length of the field assignees is too long. Array must be of length <= 10.'); }); - describe('CasePostRequestRt', () => { - const defaultRequest = { - description: 'A description', - tags: ['new', 'case'], - title: 'My new case', - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - settings: { - syncAlerts: true, - }, - owner: 'cases', - severity: CaseSeverity.LOW, - assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + it('does not throw an error with empty assignees', async () => { + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] })) + ).toContain('No errors!'); + }); + + it('does not throw an error with undefined assignees', async () => { + const { assignees, ...rest } = defaultRequest; + + expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + }); + + it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { + const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description })) + ).toContain('The length of the description is too long. The maximum length is 30000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + + expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain( + 'The length of the field tags is too long. Array must be of length <= 200.' + ); + }); + + it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { + const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] })) + ).toContain('The length of the tag is too long. The maximum length is 256.'); + }); + + it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { + const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + + expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain( + 'The length of the title is too long. The maximum length is 160.' + ); + }); + + it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { + const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category })) + ).toContain('The length of the category is too long. The maximum length is 50.'); + }); + + it('removes foo:bar attributes from customFields', () => { + const customField = { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], }; - it('has expected attributes in request', () => { - const query = CasePostRequestRt.decode(defaultRequest); + const query = CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [{ ...customField, foo: 'bar' }], + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, customFields: [{ ...customField }] }, }); + }); - it('removes foo:bar attributes from request', () => { - const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('removes foo:bar attributes from field inside customFields', () => { + const customField = { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }; - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + const query = CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [{ ...customField, foo: 'bar' }], }); - it('removes foo:bar attributes from connector', () => { - const query = CasePostRequestRt.decode({ - ...defaultRequest, - connector: { ...defaultRequest.connector, foo: 'bar' }, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, customFields: [{ ...customField }] }, + }); + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], }); - it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { - const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('does not throw an error with undefined customFields', async () => { + const { customFields, ...rest } = defaultRequest; - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees })) - ).toContain('The length of the field assignees is too long. Array must be of length <= 10.'); + expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)], + }, + ], + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); +}); + +describe('CasesFindRequestRt', () => { + const defaultRequest = { + tags: ['new', 'case'], + status: CaseStatuses.open, + severity: CaseSeverity.LOW, + assignees: ['damaged_racoon'], + reporters: ['damaged_racoon'], + defaultSearchOperator: 'AND', + from: 'now', + page: '1', + perPage: '10', + search: 'search text', + searchFields: ['title', 'description'], + to: '1w', + sortOrder: 'desc', + sortField: 'createdAt', + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = CasesFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, }); + }); - it('does not throw an error with empty assignees', async () => { - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] })) - ).toContain('No errors!'); + it('removes foo:bar attributes from request', () => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, }); + }); - it('does not throw an error with undefined assignees', async () => { - const { assignees, ...rest } = defaultRequest; + const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys); - expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + it.each(searchFields)('succeeds with %s as searchFields', (field) => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 }, }); + }); - it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { - const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys); + it.each(sortFields)('succeeds with %s as sortField', (sortField) => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, sortField, page: 1, perPage: 10 }, + }); + }); + + it('removes rootSearchField when passed', () => { + expect( + PathReporter.report( + CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] }) + ) + ).toContain('No errors!'); + }); + + describe('errors', () => { + it('throws error when invalid searchField passed', () => { expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description })) - ).toContain('The length of the description is too long. The maximum length is 30000.'); + PathReporter.report( + CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' }) + ) + ).not.toContain('No errors!'); }); - it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { - const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + it('throws error when invalid sortField passed', () => { + expect( + PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' })) + ).not.toContain('No errors!'); + }); - expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain( - 'The length of the field tags is too long. Array must be of length <= 200.' + it('succeeds when valid parameters passed', () => { + expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain( + 'No errors!' ); }); - it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { - const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] })) - ).toContain('The length of the tag is too long. The maximum length is 256.'); + expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain( + 'The length of the field category is too long. Array must be of length <= 100.' + ); }); - it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { - const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => { + const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar'); - expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain( - 'The length of the title is too long. The maximum length is 160.' + expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain( + 'The length of the field tags is too long. Array must be of length <= 100.' ); }); - it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { - const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => { + const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar'); - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category })) - ).toContain('The length of the category is too long. The maximum length is 50.'); + expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain( + 'The length of the field assignees is too long. Array must be of length <= 100.' + ); + }); + + it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => { + const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar'); + + expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain( + 'The length of the field reporters is too long. Array must be of length <= 100.' + ); }); }); +}); - describe('CasesFindRequestRt', () => { +describe('Status', () => { + describe('CasesStatusRequestRt', () => { const defaultRequest = { - tags: ['new', 'case'], - status: CaseStatuses.open, - severity: CaseSeverity.LOW, - assignees: ['damaged_racoon'], - reporters: ['damaged_racoon'], - defaultSearchOperator: 'AND', - from: 'now', - page: '1', - perPage: '10', - search: 'search text', - searchFields: ['title', 'description'], - to: '1w', - sortOrder: 'desc', - sortField: 'createdAt', + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', owner: 'cases', }; it('has expected attributes in request', () => { - const query = CasesFindRequestRt.decode(defaultRequest); + const query = CasesStatusRequestRt.decode(defaultRequest); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, page: 1, perPage: 10 }, + right: defaultRequest, }); }); - it('removes foo:bar attributes from request', () => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('has removes foo:bar attributes from request', () => { + const query = CasesStatusRequestRt.decode({ ...defaultRequest, foo: 'bar' }); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, page: 1, perPage: 10 }, + right: defaultRequest, }); }); + }); - const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys); + describe('CasesStatusResponseRt', () => { + const defaultResponse = { + count_closed_cases: 1, + count_in_progress_cases: 2, + count_open_cases: 1, + }; - it.each(searchFields)('succeeds with %s as searchFields', (field) => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field }); + it('has expected attributes in response', () => { + const query = CasesStatusResponseRt.decode(defaultResponse); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 }, + right: defaultResponse, }); }); - const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys); - - it.each(sortFields)('succeeds with %s as sortField', (sortField) => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField }); + it('removes foo:bar attributes from response', () => { + const query = CasesStatusResponseRt.decode({ ...defaultResponse, foo: 'bar' }); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, sortField, page: 1, perPage: 10 }, - }); - }); - - it('removes rootSearchField when passed', () => { - expect( - PathReporter.report( - CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] }) - ) - ).toContain('No errors!'); - }); - - describe('errors', () => { - it('throws error when invalid searchField passed', () => { - expect( - PathReporter.report( - CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' }) - ) - ).not.toContain('No errors!'); - }); - - it('throws error when invalid sortField passed', () => { - expect( - PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' })) - ).not.toContain('No errors!'); - }); - - it('succeeds when valid parameters passed', () => { - expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain( - 'No errors!' - ); - }); - - it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { - const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain( - 'The length of the field category is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => { - const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain( - 'The length of the field tags is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => { - const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain( - 'The length of the field assignees is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => { - const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain( - 'The length of the field reporters is too long. Array must be of length <= 100.' - ); + right: defaultResponse, }); }); }); @@ -478,6 +592,18 @@ describe('CasePatchRequestRt', () => { id: 'basic-case-id', version: 'WzQ3LDFd', description: 'Updated description', + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; it('has expected attributes in request', () => { @@ -555,6 +681,44 @@ describe('CasePatchRequestRt', () => { PathReporter.report(CasePatchRequestRt.decode({ ...defaultRequest, category })) ).toContain('The length of the category is too long. The maximum length is 50.'); }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }); + + expect( + PathReporter.report( + CasePatchRequestRt.decode({ + ...defaultRequest, + customFields, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + CasePatchRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)], + }, + ], + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); }); describe('CasesPatchRequestRt', () => { diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 7a92c1f32ca12..f808080cc2f0d 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -21,6 +21,9 @@ import { MAX_BULK_GET_CASES, MAX_CATEGORY_FILTER_LENGTH, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS, } from '../../../constants'; import { limitedStringSchema, @@ -28,6 +31,7 @@ import { NonEmptyString, paginationSchema, } from '../../../schema'; +import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt } from '../../domain'; import { CaseRt, CaseSettingsRt, @@ -40,10 +44,35 @@ import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseUserProfileRt, UserRt } from '../../domain/user/v1'; import { CasesStatusResponseRt } from '../stats/v1'; +const CaseCustomFieldWithValidationValueRt = limitedArraySchema({ + codec: limitedStringSchema({ + fieldName: 'value', + min: 0, + max: MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + }), + fieldName: 'value', + min: 0, + max: MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS, +}); + +const CaseCustomFieldTextWithValidationRt = rt.strict({ + key: rt.string, + type: CustomFieldTextTypeRt, + value: rt.union([CaseCustomFieldWithValidationValueRt, rt.null]), +}); + +const CustomFieldRt = rt.union([CaseCustomFieldTextWithValidationRt, CaseCustomFieldToggleRt]); + +const CustomFieldsRt = limitedArraySchema({ + codec: CustomFieldRt, + fieldName: 'customFields', + min: 0, + max: MAX_CUSTOM_FIELDS_PER_CASE, +}); + /** * Create case */ - export const CasePostRequestRt = rt.intersection([ rt.strict({ /** @@ -104,6 +133,10 @@ export const CasePostRequestRt = rt.intersection([ limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), rt.null, ]), + /** + * The list of custom field values of the case. + */ + customFields: CustomFieldsRt, }) ), ]); @@ -358,6 +391,10 @@ export const CasePatchRequestRt = rt.intersection([ limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), rt.null, ]), + /** + * Custom fields of the case + */ + customFields: CustomFieldsRt, }) ), /** @@ -448,3 +485,4 @@ export type GetReportersResponse = rt.TypeOf; export type CasesBulkGetRequest = rt.TypeOf; export type CasesBulkGetResponse = rt.TypeOf; export type GetRelatedCasesByAlertResponse = rt.TypeOf; +export type CaseRequestCustomFields = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index d5756a606d43b..b69e7701db69c 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -5,12 +5,23 @@ * 2.0. */ +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { v4 as uuidv4 } from 'uuid'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_KEY_LENGTH, + MAX_CUSTOM_FIELD_LABEL_LENGTH, +} from '../../../constants'; import { ConnectorTypes } from '../../domain/connector/v1'; +import { CustomFieldTypes } from '../../domain/custom_field/v1'; import { CaseConfigureRequestParamsRt, ConfigurationPatchRequestRt, ConfigurationRequestRt, GetConfigurationFindRequestRt, + CustomFieldConfigurationWithoutTypeRt, + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, } from './v1'; describe('configure', () => { @@ -37,6 +48,47 @@ describe('configure', () => { }); }); + it('has expected attributes in request with customFields', () => { + const request = { + ...defaultRequest, + customFields: [ + { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }); + + expect( + PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, customFields }))[0] + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}` + ); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -63,6 +115,49 @@ describe('configure', () => { }); }); + it('has expected attributes in request with customFields', () => { + const request = { + ...defaultRequest, + customFields: [ + { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }); + + expect( + PathReporter.report( + ConfigurationPatchRequestRt.decode({ ...defaultRequest, customFields }) + )[0] + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}` + ); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -120,4 +215,136 @@ describe('configure', () => { }); }); }); + + describe('CustomFieldConfigurationWithoutTypeRt', () => { + const defaultRequest = { + key: 'custom_field_key', + label: 'Custom field label', + required: false, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('limits key to 36 characters', () => { + const longKey = 'x'.repeat(MAX_CUSTOM_FIELD_KEY_LENGTH + 1); + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key: longKey }) + ) + ).toContain('The length of the key is too long. The maximum length is 36.'); + }); + + it('returns an error if they key is not in the expected format', () => { + const key = 'Not a proper key'; + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }) + ) + ).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`); + }); + + it('accepts a uuid as a key', () => { + const key = uuidv4(); + + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('accepts a slug as a key', () => { + const key = 'abc_key-1'; + + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('limits label to 50 characters', () => { + const longLabel = 'x'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, label: longLabel }) + ) + ).toContain('The length of the label is too long. The maximum length is 50.'); + }); + }); + + describe('TextCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_text_custom_field', + label: 'Text Custom Field', + type: CustomFieldTypes.TEXT, + required: true, + }; + + it('has expected attributes in request', () => { + const query = TextCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('ToggleCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_toggle_custom_field', + label: 'Toggle Custom Field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + + it('has expected attributes in request', () => { + const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index fffff5310f6af..8f98b760c9186 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -6,10 +6,74 @@ */ import * as rt from 'io-ts'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_KEY_LENGTH, + MAX_CUSTOM_FIELD_LABEL_LENGTH, +} from '../../../constants'; +import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; +import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; -import { ConfigurationBasicWithoutOwnerRt, CasesConfigureBasicRt } from '../../domain/configure/v1'; +import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; +import { CaseConnectorRt } from '../../domain/connector/v1'; -export const ConfigurationRequestRt = CasesConfigureBasicRt; +export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ + /** + * key of custom field + */ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_CUSTOM_FIELD_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + /** + * label of custom field + */ + label: limitedStringSchema({ fieldName: 'label', min: 1, max: MAX_CUSTOM_FIELD_LABEL_LENGTH }), + /** + * custom field options - required + */ + required: rt.boolean, +}); + +export const TextCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldTextTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const ToggleCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldToggleTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const CustomFieldsConfigurationRt = limitedArraySchema({ + codec: rt.union([TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt]), + min: 0, + max: MAX_CUSTOM_FIELDS_PER_CASE, + fieldName: 'customFields', +}); + +export const ConfigurationRequestRt = rt.intersection([ + rt.strict({ + /** + * The external connector + */ + connector: CaseConnectorRt, + /** + * Whether to close the case after it has been synced with the external system + */ + closure_type: ClosureTypeRt, + /** + * The plugin owner that manages this configuration + */ + owner: rt.string, + }), + rt.exact( + rt.partial({ + customFields: CustomFieldsConfigurationRt, + }) + ), +]); export const GetConfigurationFindRequestRt = rt.exact( rt.partial({ @@ -26,7 +90,13 @@ export const CaseConfigureRequestParamsRt = rt.strict({ }); export const ConfigurationPatchRequestRt = rt.intersection([ - rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)), + rt.exact( + rt.partial({ + closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, + connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, + customFields: CustomFieldsConfigurationRt, + }) + ), rt.strict({ version: rt.string }), ]); diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts index ba04082519c8c..d7dc74f6c3cb4 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts @@ -74,6 +74,18 @@ const basicCase = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; describe('RelatedCaseRt', () => { @@ -170,6 +182,18 @@ describe('CaseAttributesRt', () => { updated_at: '2020-02-20T15:02:57.995Z', updated_by: null, category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; it('has expected attributes in request', () => { diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index e4e16b7fb33a7..d8da843e46a0c 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -11,6 +11,7 @@ import { ExternalServiceRt } from '../external_service/v1'; import { CaseAssigneesRt, UserRt } from '../user/v1'; import { CaseConnectorRt } from '../connector/v1'; import { AttachmentRt } from '../attachment/v1'; +import { CaseCustomFieldsRt } from '../custom_field/v1'; export { CaseStatuses }; @@ -92,6 +93,11 @@ const CaseBasicRt = rt.strict({ * The category of the case. */ category: rt.union([rt.string, rt.null]), + /** + * An array containing the possible, + * user-configured custom fields. + */ + customFields: CaseCustomFieldsRt, }); export const CaseAttributesRt = rt.intersection([ diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 70b181ef004e6..9af0d2b474b76 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -6,7 +6,14 @@ */ import { ConnectorTypes } from '../connector/v1'; -import { ConfigurationAttributesRt, ConfigurationRt } from './v1'; +import { CustomFieldTypes } from '../custom_field/v1'; +import { + ConfigurationAttributesRt, + ConfigurationRt, + CustomFieldConfigurationWithoutTypeRt, + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, +} from './v1'; describe('configure', () => { const serviceNow = { @@ -23,10 +30,25 @@ describe('configure', () => { fields: null, }; + const textCustomField = { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }; + + const toggleCustomField = { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + describe('ConfigurationAttributesRt', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', + customFields: [textCustomField, toggleCustomField], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', created_by: { @@ -47,7 +69,10 @@ describe('configure', () => { expect(query).toStrictEqual({ _tag: 'Right', - right: defaultRequest, + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, }); }); @@ -56,7 +81,25 @@ describe('configure', () => { expect(query).toStrictEqual({ _tag: 'Right', - right: defaultRequest, + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, + }); + }); + + it('removes foo:bar attributes from custom fields', () => { + const query = ConfigurationAttributesRt.decode({ + ...defaultRequest, + customFields: [{ ...textCustomField, foo: 'bar' }, toggleCustomField], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, }); }); }); @@ -65,6 +108,7 @@ describe('configure', () => { const defaultRequest = { connector: serviceNow, closure_type: 'close-by-user', + customFields: [], created_at: '2020-02-19T23:06:33.798Z', created_by: { full_name: 'Leslie Knope', @@ -116,4 +160,84 @@ describe('configure', () => { }); }); }); + + describe('CustomFieldConfigurationWithoutTypeRt', () => { + const defaultRequest = { + key: 'custom_field_key', + label: 'Custom field label', + required: false, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('TextCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_text_custom_field', + label: 'Text Custom Field', + type: CustomFieldTypes.TEXT, + required: true, + }; + + it('has expected attributes in request', () => { + const query = TextCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('ToggleCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_toggle_custom_field', + label: 'Toggle Custom Field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + + it('has expected attributes in request', () => { + const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 664793c23b198..56a144c248c2a 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -8,8 +8,44 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; +import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; -const ClosureTypeRt = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); +export const ClosureTypeRt = rt.union([ + rt.literal('close-by-user'), + rt.literal('close-by-pushing'), +]); + +export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ + /** + * key of custom field + */ + key: rt.string, + /** + * label of custom field + */ + label: rt.string, + /** + * custom field options - required + */ + required: rt.boolean, +}); + +export const TextCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldTextTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const ToggleCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldToggleTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const CustomFieldConfigurationRt = rt.union([ + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, +]); + +export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** @@ -20,6 +56,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * Whether to close the case after it has been synced with the external system */ closure_type: ClosureTypeRt, + /** + * The custom fields configured for the case + */ + customFields: CustomFieldsConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -57,6 +97,8 @@ export const ConfigurationRt = rt.intersection([ export const ConfigurationsRt = rt.array(ConfigurationRt); +export type CustomFieldsConfiguration = rt.TypeOf; +export type CustomFieldConfiguration = rt.TypeOf; export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts b/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts new file mode 100644 index 0000000000000..2100b221239a9 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { CaseCustomFieldRt } from './v1'; + +describe('CaseCustomFieldRt', () => { + it.each([ + [ + 'type text value text', + { + key: 'string_custom_field_1', + type: 'text', + value: ['this is a text field value'], + }, + ], + [ + 'type text value null', + { + key: 'string_custom_field_2', + type: 'text', + value: null, + }, + ], + [ + 'type toggle value boolean', + { + key: 'toggle_custom_field_1', + type: 'toggle', + value: true, + }, + ], + [ + 'type toggle value null', + { + key: 'toggle_custom_field_2', + type: 'toggle', + value: null, + }, + ], + ])(`has expected attributes for customField with %s`, (_, customField) => { + const query = CaseCustomFieldRt.decode(customField); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: customField, + }); + }); + + it('fails if text type and value do not match expected attributes in request', () => { + const query = CaseCustomFieldRt.decode({ + key: 'text_custom_field_1', + type: 'text', + value: [1], + }); + + expect(PathReporter.report(query)[0]).toContain('Invalid value 1 supplied'); + }); + + it('fails if toggle type and value do not match expected attributes in request', () => { + const query = CaseCustomFieldRt.decode({ + key: 'list_custom_field_1', + type: 'toggle', + value: 'hello', + }); + + expect(PathReporter.report(query)[0]).toContain('Invalid value "hello" supplied'); + }); +}); diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts new file mode 100644 index 0000000000000..44a8a01740ea9 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as rt from 'io-ts'; + +export enum CustomFieldTypes { + TEXT = 'text', + TOGGLE = 'toggle', +} + +export const CustomFieldTextTypeRt = rt.literal(CustomFieldTypes.TEXT); +export const CustomFieldToggleTypeRt = rt.literal(CustomFieldTypes.TOGGLE); + +const CaseCustomFieldTextRt = rt.strict({ + key: rt.string, + type: CustomFieldTextTypeRt, + value: rt.union([rt.array(rt.string), rt.null]), +}); + +export const CaseCustomFieldToggleRt = rt.strict({ + key: rt.string, + type: CustomFieldToggleTypeRt, + value: rt.union([rt.boolean, rt.null]), +}); + +export const CaseCustomFieldRt = rt.union([CaseCustomFieldTextRt, CaseCustomFieldToggleRt]); +export const CaseCustomFieldsRt = rt.array(CaseCustomFieldRt); + +export type CaseCustomFields = rt.TypeOf; +export type CaseCustomField = rt.TypeOf; +export type CaseCustomFieldToggle = rt.TypeOf; +export type CaseCustomFieldText = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/index.ts b/x-pack/plugins/cases/common/types/domain/index.ts index c46b34d182877..ef317908b4627 100644 --- a/x-pack/plugins/cases/common/types/domain/index.ts +++ b/x-pack/plugins/cases/common/types/domain/index.ts @@ -7,6 +7,7 @@ // Latest export * from './configure/latest'; +export * from './custom_field/latest'; export * from './user_action/latest'; export * from './external_service/latest'; export * from './case/latest'; @@ -16,6 +17,7 @@ export * from './attachment/latest'; // V1 export * as configureDomainV1 from './configure/v1'; +export * as customFieldDomainV1 from './custom_field/v1'; export * as userActionDomainV1 from './user_action/v1'; export * as externalServiceDomainV1 from './external_service/v1'; export * as caseDomainV1 from './case/v1'; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts index 3cdb80ed534d1..22c51e1da4d10 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts @@ -25,6 +25,7 @@ export const UserActionTypes = { create_case: 'create_case', delete_case: 'delete_case', category: 'category', + customFields: 'customFields', } as const; type UserActionActionTypeKeys = keyof typeof UserActionTypes; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts index 17e4777d18afa..b0e0ce59945be 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts @@ -74,6 +74,33 @@ describe('Create case', () => { }); }); + it('customFields are decoded correctly', () => { + const customFields = [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ]; + + const defaultRequestWithCustomFields = { + ...defaultRequest, + payload: { ...defaultRequest.payload, customFields }, + }; + + const query = CreateCaseUserActionRt.decode(defaultRequestWithCustomFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequestWithCustomFields, + }); + }); + it('removes foo:bar attributes from request', () => { const query = CreateCaseUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -159,6 +186,33 @@ describe('Create case', () => { }); }); + it('customFields are decoded correctly', () => { + const customFields = [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ]; + + const defaultRequestWithCustomFields = { + ...defaultRequest, + payload: { ...defaultRequest.payload, customFields }, + }; + + const query = CreateCaseUserActionWithoutConnectorIdRt.decode(defaultRequestWithCustomFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequestWithCustomFields, + }); + }); + it('removes foo:bar attributes from request', () => { const query = CreateCaseUserActionWithoutConnectorIdRt.decode({ ...defaultRequest, diff --git a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts index 51eba91624b85..0a0938c9e4b2a 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts @@ -13,6 +13,7 @@ import { ConnectorUserActionPayloadRt, ConnectorUserActionPayloadWithoutConnectorIdRt, } from '../connector/v1'; +import { CustomFieldsUserActionPayloadRt } from '../custom_fields/v1'; import { DescriptionUserActionPayloadRt } from '../description/v1'; import { SettingsUserActionPayloadRt } from '../settings/v1'; import { TagsUserActionPayloadRt } from '../tags/v1'; @@ -36,6 +37,7 @@ const CommonPayloadAttributesRt = rt.strict({ const OptionalPayloadAttributesRt = rt.exact( rt.partial({ category: CategoryUserActionPayloadRt.type.props.category, + customFields: CustomFieldsUserActionPayloadRt.type.props.customFields, }) ); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts new file mode 100644 index 0000000000000..39d3f9a5f08c1 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UserActionTypes } from '../action/v1'; +import { CustomFieldsUserActionPayloadRt, CustomFieldsUserActionRt } from './v1'; + +describe('Custom field', () => { + describe('CustomFieldsUserActionPayloadRt', () => { + const defaultRequest = { + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value'], + }, + ], + }; + + it('has expected attributes in request', () => { + const query = CustomFieldsUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldsUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CustomFieldsUserActionRt', () => { + const defaultRequest = { + type: UserActionTypes.customFields, + payload: { + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value'], + }, + ], + }, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldsUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldsUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = CustomFieldsUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from the field', () => { + const query = CustomFieldsUserActionRt.decode({ + ...defaultRequest, + payload: { + ...defaultRequest.payload, + customFields: [{ ...defaultRequest.payload.customFields[0], foo: 'bar' }], + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts new file mode 100644 index 0000000000000..35a0377e03d60 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { CaseCustomFieldsRt } from '../../custom_field/v1'; +import { UserActionTypes } from '../action/v1'; + +export const CustomFieldsUserActionPayloadRt = rt.strict({ + customFields: CaseCustomFieldsRt, +}); + +export const CustomFieldsUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.customFields), + payload: CustomFieldsUserActionPayloadRt, +}); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/v1.ts index 365e21e305996..fdef2a9530e54 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/v1.ts @@ -22,6 +22,7 @@ import { SeverityUserActionRt } from './severity/v1'; import { StatusUserActionRt } from './status/v1'; import { TagsUserActionRt } from './tags/v1'; import { TitleUserActionRt } from './title/v1'; +import { CustomFieldsUserActionRt } from './custom_fields/v1'; export { UserActionTypes, UserActionActions } from './action/v1'; export { StatusUserActionRt } from './status/v1'; @@ -59,6 +60,7 @@ const BasicUserActionsRt = rt.union([ AssigneesUserActionRt, DeleteCaseUserActionRt, CategoryUserActionRt, + CustomFieldsUserActionRt, ]); const CommonUserActionsWithIdsRt = rt.union([BasicUserActionsRt, CommentUserActionRt]); @@ -151,3 +153,4 @@ export type CreateCaseUserAction = UserAction >; +export type CustomFieldsUserAction = UserAction>; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index e2e453aaaf278..2a76e56a59fe0 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -25,6 +25,7 @@ import type { Attachment, ExternalReferenceAttachment, PersistableStateAttachment, + Configuration, } from '../types/domain'; import type { CasePatchRequest, @@ -110,6 +111,7 @@ export type CasesMetrics = SnakeToCamelCase; export type CaseUpdateRequest = SnakeToCamelCase; export type CaseConnectors = SnakeToCamelCase; export type CaseUsers = GetCaseUsersResponse; +export type CaseUICustomField = CaseUI['customFields'][number]; export interface ResolvedCase { case: CaseUI; @@ -118,6 +120,13 @@ export interface ResolvedCase { aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose']; } +export type CasesConfigurationUI = Pick< + SnakeToCamelCase, + 'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version' +>; + +export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; + export type SortOrder = 'asc' | 'desc'; export const SORT_ORDER_VALUES: SortOrder[] = ['asc', 'desc']; @@ -205,6 +214,7 @@ export type UpdateKey = keyof Pick< | 'severity' | 'assignees' | 'category' + | 'customFields' >; export interface UpdateByKey { diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index c3fef89e021dd..b36a745179833 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -4579,6 +4579,54 @@ ], "default": "low" }, + "custom_fields_property": { + "type": "array", + "description": "Values for custom fields of a case", + "minItems": 0, + "maxItems": 5, + "items": { + "type": "object", + "required": [ + "key", + "type", + "field" + ], + "properties": { + "key": { + "description": "The key identifying the custom field.", + "type": "string" + }, + "type": { + "description": "The type of the custom field. Should match the key and how the custom field was configured", + "type": "string" + }, + "field": { + "description": "An object containing the value of the field.", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "The value of the field.", + "nullable": true, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + } + }, "create_case_request": { "title": "Create case request", "description": "The create case API request body varies depending on the type of connector.", @@ -4652,6 +4700,9 @@ "description": "A title for the case.", "type": "string", "maxLength": 160 + }, + "customFields": { + "$ref": "#/components/schemas/custom_fields_property" } } }, @@ -5296,6 +5347,9 @@ "version": { "description": "The current version of the case. To determine this value, use the get case or find cases APIs.", "type": "string" + }, + "customFields": { + "$ref": "#/components/schemas/custom_fields_property" } } } diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index f818d279a2e27..ddee756120e01 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -2890,6 +2890,38 @@ components: - low - medium default: low + custom_fields_property: + type: array + description: Values for custom fields of a case + minItems: 0 + maxItems: 5 + items: + type: object + required: + - key + - type + - field + properties: + key: + description: The key identifying the custom field. + type: string + type: + description: The type of the custom field. Should match the key and how the custom field was configured + type: string + field: + description: An object containing the value of the field. + type: object + required: + - value + properties: + value: + description: The value of the field. + nullable: true + type: array + items: + anyOf: + - type: string + - type: boolean create_case_request: title: Create case request description: The create case API request body varies depending on the type of connector. @@ -2938,6 +2970,8 @@ components: description: A title for the case. type: string maxLength: 160 + customFields: + $ref: '#/components/schemas/custom_fields_property' case_response_closed_by_properties: title: Case response properties for closed_by type: object @@ -3403,6 +3437,8 @@ components: version: description: The current version of the case. To determine this value, use the get case or find cases APIs. type: string + customFields: + $ref: '#/components/schemas/custom_fields_property' searchFieldsType: type: string description: The fields to perform the `simple_query_string` parsed query against. diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml index 1092975985dd0..7b3d8d8219ff7 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml @@ -3,12 +3,12 @@ description: >- The create case API request body varies depending on the type of connector. type: object required: - - connector - - description - - owner - - settings - - tags - - title + - connector + - description + - owner + - settings + - tags + - title properties: assignees: $ref: 'assignees.yaml' @@ -45,4 +45,6 @@ properties: title: description: A title for the case. type: string - maxLength: 160 \ No newline at end of file + maxLength: 160 + customFields: + $ref: 'custom_fields_property.yaml' diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml new file mode 100644 index 0000000000000..2b682960bd1f0 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml @@ -0,0 +1,31 @@ +type: array +description: Values for custom fields of a case +minItems: 0 +maxItems: 5 +items: + type: object + required: + - key + - type + - field + properties: + key: + description: The key identifying the custom field. + type: string + type: + description: The type of the custom field. Should match the key and how the custom field was configured + type: string + field: + description: An object containing the value of the field. + type: object + required: + - value + properties: + value: + description: The value of the field. + nullable: true + type: array + items: + anyOf: + - type: string + - type: boolean diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml index df3e7ab17d674..4bbac95630e47 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml @@ -58,3 +58,5 @@ properties: version: description: The current version of the case. To determine this value, use the get case or find cases APIs. type: string + customFields: + $ref: 'custom_fields_property.yaml' diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 9dea7a3413f95..7295a08cfd51a 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -192,12 +192,8 @@ export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldR defaultMessage: 'A name is required.', }); -export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', { - defaultMessage: 'Configure cases', -}); - export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', { - defaultMessage: 'Edit external connection', + defaultMessage: 'Settings', }); export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', { @@ -380,3 +376,12 @@ export const ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_TAG_CUSTOM_OPTION_LABEL export const ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_CATEGORY_CUSTOM_OPTION_LABEL('{searchValue}'); + +export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.badge.experimentalLabel', { + defaultMessage: 'Technical preview', +}); + +export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.badge.experimentalDesc', { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 7661242fe9153..d34be81d499ff 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -138,46 +138,6 @@ describe('AllCases', () => { }); }); - it('should not allow the user to enter configuration page with basic license', async () => { - useGetActionLicenseMock.mockReturnValue({ - ...defaultActionLicense, - data: { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: false, - }, - }); - - const result = appMockRender.render(); - - await waitFor(() => { - expect(result.getByTestId('configure-case-button')).toBeDisabled(); - }); - }); - - it('should allow the user to enter configuration page with gold license and above', async () => { - useGetActionLicenseMock.mockReturnValue({ - ...defaultActionLicense, - data: { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - }); - - const result = appMockRender.render(); - - await waitFor(() => { - expect(result.getByTestId('configure-case-button')).not.toBeDisabled(); - }); - }); - it('should render the case callouts', async () => { const result = appMockRender.render(); await waitFor(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index 59b26b921eae0..aafb287eed869 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -54,7 +54,6 @@ export const NavButtons: FunctionComponent = ({ actionsErrors }) => { {actionsErrors[0].description} : <>} titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 68c51f78c868a..0ba1f2214bfc0 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -10,13 +10,14 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; +import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import { useGetCaseUsers } from '../../../containers/use_get_case_users'; import { useGetCaseConnectors } from '../../../containers/use_get_case_connectors'; import { useCasesFeatures } from '../../../common/use_cases_features'; import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; import { useGetSupportedActionConnectors } from '../../../containers/configure/use_get_supported_action_connectors'; import type { CaseSeverity, CaseStatuses } from '../../../../common/types/domain'; -import type { UseFetchAlertData } from '../../../../common/ui/types'; +import type { CaseUICustomField, UseFetchAlertData } from '../../../../common/ui/types'; import type { CaseUI } from '../../../../common'; import { EditConnector } from '../../edit_connector'; import type { CasesNavigation } from '../../links'; @@ -38,6 +39,7 @@ import { CaseViewTabs } from '../case_view_tabs'; import { Description } from '../../description'; import { EditCategory } from './edit_category'; import { parseCaseUsers } from '../../utils'; +import { CustomFields } from './custom_fields'; export const CaseViewActivity = ({ ruleDetailsNavigation, @@ -72,6 +74,8 @@ export const CaseViewActivity = ({ const { data: caseUsers, isLoading: isLoadingCaseUsers } = useGetCaseUsers(caseData.id); + const { data: casesConfiguration } = useGetCaseConfiguration(); + const { userProfiles, reporterAsArray } = parseCaseUsers({ caseUsers, createdBy: caseData.createdBy, @@ -148,6 +152,16 @@ export const CaseViewActivity = ({ [onUpdateField] ); + const onSubmitCustomFields = useCallback( + (customFields: CaseUICustomField[]) => { + onUpdateField({ + key: 'customFields', + value: customFields, + }); + }, + [onUpdateField] + ); + const handleUserActionsActivityChanged = useCallback( (params: UserActivityParams) => { setUserActivityQueryParams((oldParams) => ({ @@ -205,6 +219,7 @@ export const CaseViewActivity = ({ onRuleDetailsClick={ruleDetailsNavigation?.onClick} caseConnectors={caseConnectors} data={caseData} + casesConfiguration={casesConfiguration} actionsNavigation={actionsNavigation} onShowAlertDetails={onShowAlertDetails} onUpdateField={onUpdateField} @@ -283,6 +298,12 @@ export const CaseViewActivity = ({ key={caseData.connector.id} /> ) : null} + diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx new file mode 100644 index 0000000000000..ce532a41d64e9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../../common/mock'; +import { readCasesPermissions, createAppMockRenderer } from '../../../common/mock'; + +import { CustomFields } from './custom_fields'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import { CustomFieldTypes } from '../../../../common/types/domain'; + +describe('Case View Page files tab', () => { + const onSubmit = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the custom fields correctly', async () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument(); + }); + + it('should render the custom fields types when the custom fields are empty', async () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument(); + }); + + it('should not show the custom fields if the configuration is empty', async () => { + appMockRender.render( + + ); + + expect(screen.queryByTestId('case-custom-field-wrapper-test_key_1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-custom-field-wrapper-test_key_2')).not.toBeInTheDocument(); + }); + + it('should sort the custom fields correctly', async () => { + const reversedConfiguration = [...customFieldsConfigurationMock].reverse(); + + appMockRender.render( + + ); + + const customFields = screen.getAllByTestId('case-custom-field-wrapper', { exact: false }); + + expect(customFields.length).toBe(2); + + expect(within(customFields[0]).getByRole('heading')).toHaveTextContent('My test label 1'); + expect(within(customFields[1]).getByRole('heading')).toHaveTextContent('My test label 2'); + }); + + it('pass the permissions to custom fields correctly', async () => { + appMockRender = createAppMockRenderer({ permissions: readCasesPermissions() }); + + appMockRender.render( + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('adds missing custom fields with no custom fields in the case', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, + ]); + }); + }); + + it('adds missing custom fields with some custom fields in the case', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); + + it('removes extra custom fields', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); + + it('updates an existing field correctly', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + customFieldsMock[0], + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx new file mode 100644 index 0000000000000..5d178c2709f62 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexItem } from '@elastic/eui'; +import type { + CasesConfigurationUI, + CasesConfigurationUICustomField, + CaseUICustomField, +} from '../../../../common/ui'; +import type { CaseUI } from '../../../../common'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { builderMap as customFieldsBuilderMap } from '../../custom_fields/builder'; +import { addOrReplaceCustomField } from '../../custom_fields/utils'; +interface Props { + isLoading: boolean; + customFields: CaseUI['customFields']; + customFieldsConfiguration: CasesConfigurationUI['customFields']; + onSubmit: (customFields: CaseUICustomField[]) => void; +} + +const CustomFieldsComponent: React.FC = ({ + isLoading, + customFields, + customFieldsConfiguration, + onSubmit, +}) => { + const { permissions } = useCasesContext(); + const sortedCustomFieldsConfiguration = useMemo( + () => sortCustomFieldsByLabel(customFieldsConfiguration), + [customFieldsConfiguration] + ); + + const onSubmitCustomField = useCallback( + (customFieldToAdd) => { + const allCustomFields = createMissingAndRemoveExtraCustomFields( + customFields, + customFieldsConfiguration + ); + + const updatedCustomFields = addOrReplaceCustomField(allCustomFields, customFieldToAdd); + + onSubmit(updatedCustomFields); + }, + [customFields, customFieldsConfiguration, onSubmit] + ); + + const customFieldsComponents = sortedCustomFieldsConfiguration.map((customFieldConf) => { + const customFieldFactory = customFieldsBuilderMap[customFieldConf.type]; + const customFieldType = customFieldFactory().build(); + + const customField = customFields.find((field) => field.key === customFieldConf.key); + + const EditComponent = customFieldType.Edit; + + return ( + + + + ); + }); + + return <>{customFieldsComponents}; +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (customFieldsConfiguration: Props['customFieldsConfiguration']) => { + return sortBy(customFieldsConfiguration, (customFieldConf) => { + return customFieldConf.label; + }); +}; + +const createMissingAndRemoveExtraCustomFields = ( + customFields: CaseUICustomField[], + confCustomFields: CasesConfigurationUICustomField[] +): CaseUICustomField[] => { + const createdCustomFields: CaseUICustomField[] = confCustomFields.map((confCustomField) => { + const foundCustomField = customFields.find( + (customField) => customField.key === confCustomField.key + ); + + if (foundCustomField) { + return foundCustomField; + } + + return { key: confCustomField.key, type: confCustomField.type, value: null }; + }); + + return createdCustomFields; +}; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index d0be88f729a69..d03b8426dac4a 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -49,6 +49,12 @@ export const REMOVED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.re defaultMessage: 'removed', }); +export const CHANGED_FIELD_TO_EMPTY = (field: string) => + i18n.translate('xpack.cases.caseView.actionLabel.changeFieldToEmpty', { + values: { field }, + defaultMessage: 'changed {field} to "None"', + }); + export const VIEW_INCIDENT = (incidentNumber: string) => i18n.translate('xpack.cases.caseView.actionLabel.viewIncident', { defaultMessage: 'View {incidentNumber}', diff --git a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts index 03747ffd4c5fe..b2258549b242c 100644 --- a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts +++ b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts @@ -93,6 +93,12 @@ export const useOnUpdateField = ({ caseData }: { caseData: CaseUI }) => { callUpdate('assignees', assigneesUpdate); } break; + case 'customFields': + const customFields = getTypedPayload(value); + if (!deepEqual(caseData.customFields, value)) { + callUpdate('customFields', customFields); + } + break; default: return null; } diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index 28267d407cb3f..908d270d9cc1e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -8,7 +8,6 @@ import type { ActionTypeConnector } from '../../../../common/types/domain'; import { ConnectorTypes } from '../../../../common/types/domain'; import type { ActionConnector } from '../../../containers/configure/types'; -import type { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; import { connectorsMock, actionTypesMock } from '../../../common/mock/connectors'; export { mappings } from '../../../containers/configure/mock'; @@ -18,35 +17,28 @@ export const actionTypes: ActionTypeConnector[] = actionTypesMock; export const searchURL = '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; -export const useCaseConfigureResponse: ReturnUseCaseConfigure = { - closureType: 'close-by-user', - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - currentConfiguration: { +export const useCaseConfigureResponse = { + data: { + closureType: 'close-by-user', connector: { + fields: null, id: 'none', name: 'none', type: ConnectorTypes.none, - fields: null, }, - closureType: 'close-by-user', + customFields: [], + mappings: [], + version: '', + id: '', }, - firstLoad: false, - loading: false, - mappings: [], - persistCaseConfigure: jest.fn(), - persistLoading: false, - refetchCaseConfigure: jest.fn(), - setClosureType: jest.fn(), - setConnector: jest.fn(), - setCurrentConfiguration: jest.fn(), - setMappings: jest.fn(), - version: '', - id: '', + isLoading: false, + refetch: jest.fn(), +}; + +export const usePersistConfigurationMockResponse = { + isLoading: false, + mutate: jest.fn(), + mutateAsync: jest.fn(), }; export const useConnectorsResponse = { diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 5272eaebad512..8b54575292d84 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -8,15 +8,19 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; -import { noUpdateCasesPermissions, TestProviders } from '../../common/mock'; +import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { useKibana } from '../../common/lib/kibana'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; import { connectors, @@ -24,24 +28,31 @@ import { useCaseConfigureResponse, useConnectorsResponse, useActionTypesResponse, + usePersistConfigurationMockResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../common/types/domain'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { useLicense } from '../../common/use_license'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); +jest.mock('../../containers/configure/use_persist_configuration'); jest.mock('../../containers/configure/use_action_types'); +jest.mock('../../common/use_license'); const useKibanaMock = useKibana as jest.Mocked; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; +const usePersistConfigurationMock = usePersistConfiguration as jest.Mock; const useGetUrlSearchMock = jest.fn(); const useGetActionTypesMock = useGetActionTypes as jest.Mock; const getAddConnectorFlyoutMock = jest.fn(); const getEditConnectorFlyoutMock = jest.fn(); +const useLicenseMock = useLicense as jest.Mock; describe('ConfigureCases', () => { beforeAll(() => { @@ -59,12 +70,14 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetActionTypesMock.mockImplementation(() => useActionTypesResponse); + useLicenseMock.mockReturnValue({ isAtLeastGold: () => true }); }); describe('rendering', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); + usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse); useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -100,25 +113,19 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - closureType: 'close-by-user', - connector: { - id: 'not-id', - name: 'unchanged', - type: ConnectorTypes.none, - fields: null, - }, - currentConfiguration: { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + closureType: 'close-by-user', connector: { id: 'not-id', name: 'unchanged', type: ConnectorTypes.none, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { @@ -145,26 +152,21 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mappings: [], - closureType: 'close-by-user', - connector: { - id: 'servicenow-1', - name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mappings: [], + closureType: 'close-by-user', connector: { id: 'servicenow-1', name: 'unchanged', type: ConnectorTypes.serviceNowITSM, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -226,24 +228,18 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'unchanged', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'servicenow-1', + id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, })); @@ -302,15 +298,22 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: true, + })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + isLoading: true, })); useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); @@ -350,13 +353,15 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - loading: true, + isLoading: true, })); + useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, })); + useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders, @@ -373,32 +378,29 @@ describe('ConfigureCases', () => { }); describe('connectors', () => { + const persistCaseConfigure = jest.fn(); let wrapper: ReactWrapper; - let persistCaseConfigure: jest.Mock; beforeEach(() => { - persistCaseConfigure = jest.fn(); - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'My connector', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'My connector', + id: 'resilient-2', name: 'My connector', - type: ConnectorTypes.jira, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, - persistCaseConfigure, })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -422,27 +424,36 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-user', + customFields: [], + id: '', + version: '', }); }); test('the text of the update button is changed successfully', () => { - useCaseConfigureMock + useGetCaseConfigurationMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'My connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'resilient-2', - name: 'My Resilient connector', - type: ConnectorTypes.resilient, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'resilient-2', + name: 'My Resilient connector', + type: ConnectorTypes.resilient, + fields: null, + }, }, })); @@ -469,26 +480,24 @@ describe('ConfigureCases', () => { beforeEach(() => { persistCaseConfigure = jest.fn(); - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'servicenow-1', - name: 'My connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'My connector', + id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.jira, + type: ConnectorTypes.serviceNowITSM, fields: null, }, - closureType: 'close-by-user', }, - persistCaseConfigure, + })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, })); useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -511,32 +520,31 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-pushing', + customFields: [], + id: '', + version: '', }); }); }); describe('user interactions', () => { beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'unchanged', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + + mapping: null, + closureType: 'close-by-user', connector: { id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); }); @@ -597,4 +605,267 @@ describe('ConfigureCases', () => { ).toBeFalsy(); }); }); + + describe('custom fields', () => { + let appMockRender: AppMockRenderer; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + persistCaseConfigure = jest.fn(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + }); + + it('renders custom field group when no custom fields available', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + }); + + it('renders custom field when available', () => { + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + ]; + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsMock, + }, + })); + + appMockRender.render(); + + expect( + screen.getByTestId(`custom-field-${customFieldsMock[0].label}-${customFieldsMock[0].type}`) + ).toBeInTheDocument(); + }); + + it('renders multiple custom field when available', () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + for (const field of customFieldsConfigurationMock) { + expect( + within(list).getByTestId(`custom-field-${field.label}-${field.type}`) + ).toBeInTheDocument(); + } + }); + + it('deletes a custom field correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [{ ...customFieldsConfigurationMock[1] }], + id: '', + version: '', + }); + }); + }); + + it('updates a custom field correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); + + userEvent.click(screen.getByTestId('text-custom-field-options')); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [ + { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!`, + required: !customFieldsConfigurationMock[0].required, + }, + { ...customFieldsConfigurationMock[1] }, + ], + id: '', + version: '', + }); + }); + }); + + it('opens fly out for when click on add field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + }); + + it('closes fly out for when click on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + + expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + }); + + it('closes fly out for when click on save field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [ + ...customFieldsConfigurationMock, + { + key: expect.anything(), + label: 'Summary', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + id: '', + version: '', + }); + }); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + }); + }); + + describe('rendering with license limitations', () => { + let appMockRender: AppMockRenderer; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + // Default setup + jest.clearAllMocks(); + useGetConnectorsMock.mockImplementation(() => ({ useConnectorsResponse })); + appMockRender = createAppMockRenderer(); + persistCaseConfigure = jest.fn(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); + + // Updated + useLicenseMock.mockReturnValue({ isAtLeastGold: () => false }); + }); + + it('should not render connectors and closure options', () => { + appMockRender.render(); + expect(screen.queryByTestId('dropdown-connectors')).not.toBeInTheDocument(); + expect(screen.queryByTestId('closure-options-radio-group')).not.toBeInTheDocument(); + }); + + it('should render custom field section', () => { + appMockRender.render(); + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + }); + + describe('when the previously selected connector doesnt appear due to license downgrade or because it was deleted', () => { + beforeEach(() => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + closureType: 'close-by-user', + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, + }, + })); + }); + + it('should not render the warning callout', () => { + expect(screen.queryByTestId('configure-cases-warning-callout')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index fcbc455345b21..42a93622f1a52 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -9,13 +9,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiCallOut, EuiLink, EuiPageBody } from '@elastic/eui'; +import { EuiCallOut, EuiFlexItem, EuiLink, EuiPageBody } from '@elastic/eui'; import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import type { ClosureType } from '../../containers/configure/types'; @@ -29,7 +30,12 @@ import { HeaderPage } from '../header_page'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { CasesDeepLinkId } from '../../common/navigation'; +import { CustomFields } from '../custom_fields'; +import { CustomFieldFlyout } from '../custom_fields/flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; +import { addOrReplaceCustomField } from '../custom_fields/utils'; +import { useLicense } from '../../common/use_license'; const FormWrapper = styled.div` ${({ theme }) => css` @@ -53,6 +59,8 @@ export const ConfigureCases: React.FC = React.memo(() => { const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure); + const license = useLicense(); + const hasMinimumLicensePermissions = license.isAtLeastGold(); const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -60,18 +68,29 @@ export const ConfigureCases: React.FC = React.memo(() => { const [editedConnectorItem, setEditedConnectorItem] = useState( null ); + const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState(false); + const [customFieldToEdit, setCustomFieldToEdit] = useState(null); const { - connector, - closureType, - loading: loadingCaseConfigure, - mappings, - persistLoading, - persistCaseConfigure, - refetchCaseConfigure, - setConnector, - setClosureType, - } = useCaseConfigure(); + data: { + id: configurationId, + version: configurationVersion, + closureType, + connector, + mappings, + customFields, + }, + isLoading: loadingCaseConfigure, + refetch: refetchCaseConfigure, + } = useGetCaseConfiguration(); + + const { + mutate: persistCaseConfigure, + mutateAsync: persistCaseConfigureAsync, + isLoading: isPersistingConfiguration, + } = usePersistConfiguration(); + + const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; const { isLoading: isLoadingConnectors, @@ -98,18 +117,31 @@ export const ConfigureCases: React.FC = React.memo(() => { async (createdConnector) => { const caseConnector = normalizeActionConnector(createdConnector); - await persistCaseConfigure({ + await persistCaseConfigureAsync({ connector: caseConnector, closureType, + customFields, + id: configurationId, + version: configurationVersion, }); + onConnectorUpdated(createdConnector); - setConnector(caseConnector); }, - [onConnectorUpdated, closureType, setConnector, persistCaseConfigure] + [ + persistCaseConfigureAsync, + closureType, + customFields, + configurationId, + configurationVersion, + onConnectorUpdated, + ] ); const isLoadingAny = - isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; + isLoadingConnectors || + isPersistingConfiguration || + loadingCaseConfigure || + isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -133,24 +165,35 @@ export const ConfigureCases: React.FC = React.memo(() => { const caseConnector = actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector(); - setConnector(caseConnector); persistCaseConfigure({ connector: caseConnector, closureType, + customFields, + id: configurationId, + version: configurationVersion, }); }, - [connectors, closureType, persistCaseConfigure, setConnector] + [ + connectors, + persistCaseConfigure, + closureType, + customFields, + configurationId, + configurationVersion, + ] ); const onChangeClosureType = useCallback( (type: ClosureType) => { - setClosureType(type); persistCaseConfigure({ connector, + customFields, + id: configurationId, + version: configurationVersion, closureType: type, }); }, - [connector, persistCaseConfigure, setClosureType] + [configurationId, configurationVersion, connector, customFields, persistCaseConfigure] ); useEffect(() => { @@ -202,6 +245,88 @@ export const ConfigureCases: React.FC = React.memo(() => { [connector.id, editedConnectorItem, editFlyoutVisible] ); + const onAddCustomFields = useCallback(() => { + setCustomFieldFlyoutVisibility(true); + }, [setCustomFieldFlyoutVisibility]); + + const onDeleteCustomField = useCallback( + (key: string) => { + const remainingCustomFields = customFields.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + customFields: [...remainingCustomFields], + id: configurationId, + version: configurationVersion, + closureType, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + persistCaseConfigure, + ] + ); + + const onEditCustomField = useCallback( + (key: string) => { + const selectedCustomField = customFields.find((item) => item.key === key); + + if (selectedCustomField) { + setCustomFieldToEdit(selectedCustomField); + } + setCustomFieldFlyoutVisibility(true); + }, + [setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields] + ); + + const onCloseAddFieldFlyout = useCallback(() => { + setCustomFieldFlyoutVisibility(false); + setCustomFieldToEdit(null); + }, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]); + + const onSaveCustomField = useCallback( + (customFieldData: CustomFieldConfiguration) => { + const updatedFields = addOrReplaceCustomField(customFields, customFieldData); + persistCaseConfigure({ + connector, + customFields: updatedFields, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setCustomFieldFlyoutVisibility(false); + setCustomFieldToEdit(null); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + persistCaseConfigure, + ] + ); + + const CustomFieldAddFlyout = customFieldFlyoutVisible ? ( + + ) : null; + return ( <> { /> - {!connectorIsValid && ( - - - - {i18n.LINK_APPROPRIATE_LICENSE} - - ), - }} + {hasMinimumLicensePermissions && ( + <> + {!connectorIsValid && ( + + + + {i18n.LINK_APPROPRIATE_LICENSE} + + ), + }} + /> + + + )} + + + + + - - + + )} - - - - + + + {ConnectorAddFlyout} {ConnectorEditFlyout} + {CustomFieldAddFlyout} diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index ada2690ab7b03..e10f6fcad2fb9 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -150,7 +150,7 @@ export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( ); export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', { - defaultMessage: 'Configure cases', + defaultMessage: 'Settings', }); export const CASES_WEBHOOK_MAPPINGS = i18n.translate( diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index b9e40df65c4a7..7bc4bfbde6162 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -27,18 +27,18 @@ import { createAppMockRenderer, TestProviders, } from '../../common/mock'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, @@ -85,7 +85,7 @@ describe('Connector', () => { useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders', async () => { diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 7422e671fa4bb..39e04f7bc0be3 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -14,7 +14,7 @@ import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { schema } from './schema'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; @@ -29,7 +29,11 @@ interface Props { const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; - const { connector: configurationConnector } = useCaseConfigure(); + + const { + data: { connector: configurationConnector }, + } = useGetCaseConfiguration(); + const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx new file mode 100644 index 0000000000000..06f6c17922221 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { CustomFields } from './custom_fields'; +import * as i18n from './translations'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render( + + + + ); + + expect(screen.getByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); + expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + for (const item of customFieldsConfigurationMock) { + expect( + screen.getByTestId(`${item.key}-${item.type}-create-custom-field`) + ).toBeInTheDocument(); + } + }); + + it('should not show the custom fields if the configuration is empty', async () => { + appMockRender.render( + + + + ); + + expect(screen.queryByText(i18n.ADDITIONAL_FIELDS)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); + }); + + it('should sort the custom fields correctly', async () => { + const reversedConfiguration = [...customFieldsConfigurationMock].reverse(); + + appMockRender.render( + + + + ); + + const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields'); + + const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); + + expect(customFields).toHaveLength(2); + + expect(customFields[0]).toHaveTextContent('My test label 1'); + expect(customFields[1]).toHaveTextContent('My test label 2'); + }); + + it('should update the custom fields', async () => { + appMockRender = createAppMockRenderer(); + + appMockRender.render( + + + + ); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + userEvent.type( + screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`), + 'hello' + ); + userEvent.click( + screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [textField.key]: 'hello', + [toggleField.key]: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx new file mode 100644 index 0000000000000..cfc80c125a7b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/custom_fields.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { CasesConfigurationUI } from '../../../common/ui'; +import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import * as i18n from './translations'; + +interface Props { + isLoading: boolean; + customFieldsConfiguration: CasesConfigurationUI['customFields']; +} + +const CustomFieldsComponent: React.FC = ({ isLoading, customFieldsConfiguration }) => { + const sortedCustomFields = useMemo( + () => sortCustomFieldsByLabel(customFieldsConfiguration), + [customFieldsConfiguration] + ); + + const customFieldsComponents = sortedCustomFields.map( + (customField: CasesConfigurationUI['customFields'][number]) => { + const customFieldFactory = customFieldsBuilderMap[customField.type]; + const customFieldType = customFieldFactory().build(); + + const CreateComponent = customFieldType.Create; + + return ( + + ); + } + ); + + if (!customFieldsConfiguration.length) { + return null; + } + + return ( + + +

{i18n.ADDITIONAL_FIELDS}

+
+ + {customFieldsComponents} +
+ ); +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { + return sortBy(configCustomFields, (configCustomField) => { + return configCustomField.label; + }); +}; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 20502756db502..e7bd2fc754f34 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -14,12 +14,12 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { NONE_CONNECTOR_ID } from '../../../common/constants'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock } from '../../containers/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import type { FormProps } from './schema'; import { schema } from './schema'; import type { CreateCaseFormProps } from './form'; import { CreateCaseForm } from './form'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { TestProviders } from '../../common/mock'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; @@ -28,13 +28,13 @@ import { useAvailableCasesOwners } from '../app/use_available_owners'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); jest.mock('../app/use_available_owners'); const useGetTagsMock = useGetTags as jest.Mock; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock; const initialCaseValue: FormProps = { @@ -45,6 +45,7 @@ const initialCaseValue: FormProps = { fields: null, syncAlerts: true, assignees: [], + customFields: {}, }; const casesFormProps: CreateCaseFormProps = { @@ -81,7 +82,7 @@ describe('CreateCaseForm', () => { useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']); useGetTagsMock.mockReturnValue({ data: ['test'] }); useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); afterEach(() => { @@ -218,6 +219,30 @@ describe('CreateCaseForm', () => { expect(descriptionInput).toHaveValue(''); }); + it('should render custom fields when available', () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + const result = render( + + + + ); + + expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + for (const item of customFieldsConfigurationMock) { + expect( + result.getByTestId(`${item.key}-${item.type}-create-custom-field`) + ).toBeInTheDocument(); + } + }); + it('should prefill the form when provided with initialValue', () => { const { getByTestId } = render( diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 1c1668ac528fb..280adaa66aeec 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -19,6 +19,7 @@ import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_ import type { ActionConnector } from '../../../common/types/domain'; import type { CasePostRequest } from '../../../common/types/api'; +import type { CasesConfigurationUI } from '../../../common/ui'; import { Title } from './title'; import { Description, fieldName as descriptionFieldName } from './description'; import { Tags } from './tags'; @@ -44,6 +45,7 @@ import { Assignees } from './assignees'; import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; import { Category } from './category'; +import { CustomFields } from './custom_fields'; interface ContainerProps { big?: boolean; @@ -64,6 +66,8 @@ const MySpinner = styled(EuiLoadingSpinner)` export interface CreateCaseFormFieldsProps { connectors: ActionConnector[]; + customFieldsConfiguration: CasesConfigurationUI['customFields']; + isLoadingCaseConfiguration: boolean; isLoadingConnectors: boolean; withSteps: boolean; owner: string[]; @@ -83,7 +87,15 @@ export interface CreateCaseFormProps extends Pick = React.memo( - ({ connectors, isLoadingConnectors, withSteps, owner, draftStorageKey }) => { + ({ + connectors, + isLoadingConnectors, + withSteps, + owner, + draftStorageKey, + customFieldsConfiguration, + isLoadingCaseConfiguration, + }) => { const { isSubmitting } = useFormContext(); const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); const availableOwners = useAvailableCasesOwners(); @@ -120,6 +132,12 @@ export const CreateCaseFormFields: React.FC = React.m + + + ), @@ -130,6 +148,8 @@ export const CreateCaseFormFields: React.FC = React.m canShowCaseSolutionSelection, availableOwners, draftStorageKey, + customFieldsConfiguration, + isLoadingCaseConfiguration, ] ); @@ -227,7 +247,9 @@ export const CreateCaseForm: React.FC = React.memo( > { useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); @@ -429,16 +436,78 @@ describe.skip('Create case', () => { await waitForComponentToUpdate(); }); + it('should submit form with custom fields', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: [ + ...customFieldsConfigurationMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'my custom field label', + required: false, + }, + ], + }, + })); + + appMockRender.render( + + + + + ); + + await waitForFormToRender(screen); + await fillFormReactTestingLib({ renderer: screen }); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + userEvent.paste( + screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`), + 'My text test value 1' + ); + + userEvent.click( + screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByTestId('create-case-submit')); + + await waitFor(() => expect(postCase).toHaveBeenCalled()); + + expect(postCase).toBeCalledWith({ + request: { + ...sampleDataWithoutTags, + customFields: [ + ...customFieldsMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + }); + }); + it('should select the default connector set in the configuration', async () => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: false, })); useGetConnectorsMock.mockReturnValue({ @@ -480,15 +549,17 @@ describe.skip('Create case', () => { }); it('should default to none if the default connector does not exist in connectors', async () => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: false, })); useGetConnectorsMock.mockReturnValue({ diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 4df39cb2d52ac..2457b964ac3fc 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -15,7 +15,7 @@ import { getNoneConnector, normalizeActionConnector } from '../configure_cases/u import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import type { CaseUI } from '../../containers/types'; +import type { CaseUI, CaseUICustomField } from '../../containers/types'; import type { CasePostRequest } from '../../../common/types/api'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; import { useCreateAttachments } from '../../containers/use_create_attachments'; @@ -25,11 +25,13 @@ import { getConnectorById, getConnectorsFormDeserializer, getConnectorsFormSerializer, + convertCustomFieldValue, } from '../utils'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; const initialCaseValue: FormProps = { description: '', @@ -41,6 +43,7 @@ const initialCaseValue: FormProps = { syncAlerts: true, selectedOwner: null, assignees: [], + customFields: {}, }; interface Props { @@ -63,6 +66,10 @@ export const FormContext: React.FC = ({ }) => { const { data: connectors = [], isLoading: isLoadingConnectors } = useGetSupportedActionConnectors(); + const { + data: { customFields: customFieldsConfiguration }, + isLoading: isLoadingCaseConfiguration, + } = useGetCaseConfiguration(); const { owner, appId } = useCasesContext(); const { isSyncAlertsEnabled } = useCasesFeatures(); const { mutateAsync: postCase } = usePostCase(); @@ -89,6 +96,30 @@ export const FormContext: React.FC = ({ return formData; }; + const transformCustomFieldsData = useCallback( + (customFields: Record) => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !customFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = customFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; + }, + [customFieldsConfiguration] + ); + const submitCase = useCallback( async ( { @@ -100,7 +131,7 @@ export const FormContext: React.FC = ({ isValid ) => { if (isValid) { - const { selectedOwner, ...userFormData } = dataWithoutConnectorId; + const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId; const caseConnector = getConnectorById(dataConnectorId, connectors); const defaultOwner = owner[0] ?? availableOwners[0]; @@ -110,6 +141,8 @@ export const FormContext: React.FC = ({ ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); + const transformedCustomFields = transformCustomFieldsData(customFields); + const trimmedData = trimUserFormData(userFormData); const theCase = await postCase({ @@ -118,6 +151,7 @@ export const FormContext: React.FC = ({ connector: connectorToUpdate, settings: { syncAlerts }, owner: selectedOwner ?? defaultOwner, + customFields: transformedCustomFields, }, }); @@ -159,6 +193,7 @@ export const FormContext: React.FC = ({ onSuccess, createAttachments, pushCaseToExternalService, + transformCustomFieldsData, ] ); @@ -175,10 +210,21 @@ export const FormContext: React.FC = ({ () => children != null ? React.Children.map(children, (child: React.ReactElement) => - React.cloneElement(child, { connectors, isLoadingConnectors }) + React.cloneElement(child, { + connectors, + isLoadingConnectors, + customFieldsConfiguration, + isLoadingCaseConfiguration, + }) ) : null, - [children, connectors, isLoadingConnectors] + [ + children, + connectors, + isLoadingConnectors, + customFieldsConfiguration, + isLoadingCaseConfiguration, + ] ); return (
{ beforeEach(() => { jest.clearAllMocks(); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index ccb11aa0f93e7..0dc6168106786 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -28,6 +28,7 @@ export const sampleData: CasePostRequest = { }, owner: SECURITY_SOLUTION_OWNER, assignees: [], + customFields: [], category: null, }; diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 778810b0db004..9d07efbf36111 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -72,11 +72,15 @@ export const schemaTags = { ], }; -export type FormProps = Omit & { +export type FormProps = Omit< + CasePostRequest, + 'connector' | 'settings' | 'owner' | 'customFields' +> & { connectorId: string; fields: ConnectorTypeFields['fields']; syncAlerts: boolean; selectedOwner?: string | null; + customFields: Record; }; export const schema: FormSchema = { diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 4bb7471e1c648..473cc40a6a3f8 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -22,6 +22,10 @@ export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitl defaultMessage: 'External Connector Fields', }); +export const ADDITIONAL_FIELDS = i18n.translate('xpack.cases.create.additionalFields', { + defaultMessage: 'Additional fields', +}); + export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', { defaultMessage: 'Sync alert status with case status', }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx new file mode 100644 index 0000000000000..d2ee25d08bfa6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CustomFieldBuilderMap } from './types'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { configureTextCustomFieldFactory } from './text/configure_text_field'; +import { configureToggleCustomFieldFactory } from './toggle/configure_toggle_field'; + +export const builderMap = Object.freeze({ + [CustomFieldTypes.TEXT]: configureTextCustomFieldFactory, + [CustomFieldTypes.TOGGLE]: configureToggleCustomFieldFactory, +} as const) as CustomFieldBuilderMap; diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx new file mode 100644 index 0000000000000..22672ab3bbfd5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import { CustomFieldsList } from '.'; + +describe('CustomFieldsList', () => { + let appMockRender: AppMockRenderer; + + const props = { + customFields: customFieldsConfigurationMock, + onDeleteCustomField: jest.fn(), + onEditCustomField: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + }); + + it('shows CustomFieldsList correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + + for (const field of customFieldsConfigurationMock) { + expect(screen.getByTestId(`custom-field-${field.label}-${field.type}`)).toBeInTheDocument(); + } + }); + + it('shows single CustomFieldsList correctly', async () => { + appMockRender.render( + + ); + + const list = screen.getByTestId('custom-fields-list'); + + expect(list).toBeInTheDocument(); + expect( + screen.getByTestId( + `custom-field-${customFieldsConfigurationMock[0].label}-${customFieldsConfigurationMock[0].type}` + ) + ).toBeInTheDocument(); + expect( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ).toBeInTheDocument(); + }); + + it('does not show any panel when custom fields', () => { + appMockRender.render(); + + expect(screen.queryAllByTestId(`custom-field-`, { exact: false })).toHaveLength(0); + }); + + describe('Delete', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows confirmation modal when deleting a field ', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + }); + + it('calls onDeleteCustomField when confirm', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(props.onDeleteCustomField).toHaveBeenCalledWith( + customFieldsConfigurationMock[0].key + ); + }); + }); + + it('does not call onDeleteCustomField when cancel', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(props.onDeleteCustomField).not.toHaveBeenCalledWith(); + }); + }); + }); + + describe('Edit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls onEditCustomField correctly', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + await waitFor(() => { + expect(props.onEditCustomField).toHaveBeenCalledWith(customFieldsConfigurationMock[0].key); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx new file mode 100644 index 0000000000000..32849a1e3ab52 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiIcon, + EuiButtonIcon, +} from '@elastic/eui'; + +import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; +import { builderMap } from '../builder'; +import { DeleteConfirmationModal } from '../delete_confirmation_modal'; + +export interface Props { + customFields: CustomFieldsConfiguration; + onDeleteCustomField: (key: string) => void; + onEditCustomField: (key: string) => void; +} + +const CustomFieldsListComponent: React.FC = (props) => { + const { customFields, onDeleteCustomField, onEditCustomField } = props; + const [selectedItem, setSelectedItem] = useState(null); + + const renderTypeLabel = (type?: CustomFieldTypes) => { + const createdBuilder = type && builderMap[type]; + + return createdBuilder && createdBuilder().label; + }; + + const onConfirm = useCallback(() => { + if (selectedItem) { + onDeleteCustomField(selectedItem.key); + } + + setSelectedItem(null); + }, [onDeleteCustomField, setSelectedItem, selectedItem]); + + const onCancel = useCallback(() => { + setSelectedItem(null); + }, []); + + const showModal = Boolean(selectedItem); + + return customFields.length ? ( + <> + + + + {customFields.map((customField) => ( + + + + + + + + + + +

{customField.label}

+
+
+ {renderTypeLabel(customField.type)} +
+
+ + + + onEditCustomField(customField.key)} + /> + + + setSelectedItem(customField)} + /> + + + +
+
+ +
+ ))} +
+ {showModal && selectedItem ? ( + + ) : null} +
+ + ) : null; +}; + +CustomFieldsListComponent.displayName = 'CustomFieldsList'; + +export const CustomFieldsList = React.memo(CustomFieldsListComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx new file mode 100644 index 0000000000000..8a2186fedbdc0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; + +describe('DeleteConfirmationModal', () => { + let appMock: AppMockRenderer; + const props = { + label: 'My custom field', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..dc418961843a1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from './translations'; + +interface ConfirmDeleteCaseModalProps { + label: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC = ({ + label, + onCancel, + onConfirm, +}) => { + return ( + + {i18n.DELETE_FIELD_DESCRIPTION} + + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx new file mode 100644 index 0000000000000..ce7a9687b89ec --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { CustomFieldFlyout } from './flyout'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; +import * as i18n from './translations'; + +describe('CustomFieldFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + customField: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-flyout-header')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-flyout-cancel')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-flyout-save')).toBeInTheDocument(); + }); + + it('calls onSaveField on save field', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('text-custom-field-options')); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: 'text', + }); + }); + }); + + it('shows error if field label is too long', async () => { + appMockRender.render(); + + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + userEvent.type(screen.getByTestId('custom-field-label-input'), message); + + await waitFor(() => { + expect( + screen.getByText(i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH)) + ).toBeInTheDocument(); + }); + }); + + it('calls onSaveField with serialized data', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: 'text', + }); + }); + }); + + it('does not call onSaveField when error', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(screen.getByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL))).toBeInTheDocument(); + }); + + expect(props.onSaveField).not.toBeCalled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('renders flyout with data when customField value exist', async () => { + appMockRender.render( + + ); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('text-custom-field-options')).toHaveAttribute('checked'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx new file mode 100644 index 0000000000000..bf2013898e0c3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { CustomFieldFormState } from './form'; +import { CustomFieldsForm } from './form'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +import * as i18n from './translations'; + +export interface CustomFieldFlyoutProps { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: CustomFieldConfiguration) => void; + customField: CustomFieldConfiguration | null; +} + +const CustomFieldFlyoutComponent: React.FC = ({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, + customField, +}) => { + const dataTestSubj = 'custom-field-flyout'; + + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ + isValid: false, + data: { key: '', label: '', type: CustomFieldTypes.TEXT, required: false }, + }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + onSaveField(data); + } + }, [onSaveField, submit]); + + return ( + + + +

{i18n.ADD_CUSTOM_FIELD}

+
+
+ + + + + + + + {i18n.CANCEL} + + + + + + + {i18n.SAVE_FIELD} + + + + + +
+ ); +}; + +CustomFieldFlyoutComponent.displayName = 'CustomFieldFlyout'; + +export const CustomFieldFlyout = React.memo(CustomFieldFlyoutComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx new file mode 100644 index 0000000000000..5ff61d838a0d6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, fireEvent, waitFor, act } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { CustomFieldFormState } from './form'; +import { CustomFieldsForm } from './form'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import * as i18n from './translations'; +import userEvent from '@testing-library/user-event'; +import { customFieldsConfigurationMock } from '../../containers/mock'; + +describe('CustomFieldsForm ', () => { + let appMockRender: AppMockRenderer; + const onChange = jest.fn(); + + const props = { + onChange, + initialValue: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('renders text as default custom field type', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(screen.getByText('Text')).toBeInTheDocument(); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('renders custom field type options', async () => { + appMockRender.render(); + + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Toggle')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).not.toHaveAttribute('disabled'); + }); + + it('renders toggle custom field type', async () => { + appMockRender.render(); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + expect(screen.getByTestId('toggle-custom-field-options')).toBeInTheDocument(); + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('serializes the data correctly if required is selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render(); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(screen.getByTestId('text-custom-field-options')); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual({ + key: expect.anything(), + label: 'Summary', + required: true, + type: 'text', + }); + }); + }); + + it('serializes the data correctly if required is not selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render(); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual({ + key: expect.anything(), + label: 'Summary', + required: false, + type: 'text', + }); + }); + }); + + it('deserializes the data correctly if required is selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('text-custom-field-options')).toHaveAttribute('checked'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual(customFieldsConfigurationMock[0]); + }); + }); + + it('deserializes the data correctly if required not selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[1].label + ); + expect(await screen.findByTestId('text-custom-field-options')).not.toHaveAttribute('checked'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual(customFieldsConfigurationMock[1]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx new file mode 100644 index 0000000000000..f4e8568281af1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { CustomFieldsConfigurationFormProps } from './schema'; +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +export interface CustomFieldFormState { + isValid: boolean | undefined; + submit: FormHook['submit']; +} + +interface Props { + onChange: (state: CustomFieldFormState) => void; + initialValue: CustomFieldConfiguration | null; +} + +// Form -> API +const formSerializer = ({ + key, + label, + type, + options, +}: CustomFieldsConfigurationFormProps): CustomFieldConfiguration => { + return { + key, + label, + type, + required: options?.required ? options.required : false, + }; +}; + +// API -> Form +const formDeserializer = ({ + key, + label, + type, + required, +}: CustomFieldConfiguration): CustomFieldsConfigurationFormProps => { + return { + key, + options: { required: Boolean(required) }, + label, + type, + }; +}; + +const FormComponent: React.FC = ({ onChange, initialValue }) => { + const keyDefaultValue = useMemo(() => uuidv4(), []); + + const { form } = useForm({ + defaultValue: initialValue ?? { + key: keyDefaultValue, + label: '', + type: CustomFieldTypes.TEXT, + required: false, + }, + options: { stripEmptyFields: false }, + schema, + serializer: formSerializer, + deserializer: formDeserializer, + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( + + + + ); +}; + +FormComponent.displayName = 'CustomFieldsForm'; + +export const CustomFieldsForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx new file mode 100644 index 0000000000000..5f7c4be9873f2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { FormFields } from './form_fields'; + +describe('FormFields ', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('disables field type selector on edit mode', async () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + }); + + it('submit data correctly', async () => { + appMockRender.render( + + + + ); + + userEvent.type(screen.getByTestId('custom-field-label-input'), 'hello'); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + label: 'hello', + type: 'toggle', + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx new file mode 100644 index 0000000000000..4ae51002e9587 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + TextField, + SelectField, + HiddenField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { EuiSelectOption } from '@elastic/eui'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { builderMap } from './builder'; + +interface FormFieldsProps { + isSubmitting?: boolean; + isEditMode?: boolean; +} + +const fieldTypeSelectOptions = (): EuiSelectOption[] => { + const options = []; + + for (const [id, builder] of Object.entries(builderMap)) { + const createdBuilder = builder(); + options.push({ value: id, text: createdBuilder.label }); + } + + return options; +}; + +const FormFieldsComponent: React.FC = ({ isSubmitting, isEditMode }) => { + const [selectedType, setSelectedType] = useState(CustomFieldTypes.TEXT); + + const handleTypeChange = useCallback( + (e) => { + setSelectedType(e.target.value); + }, + [setSelectedType] + ); + + const builtCustomField = useMemo(() => { + const builder = builderMap[selectedType]; + + if (builder == null) { + return null; + } + + const customFieldBuilder = builder(); + + return customFieldBuilder.build(); + }, [selectedType]); + + const Configure = builtCustomField?.Configure; + const options = fieldTypeSelectOptions(); + + return ( + <> + + + + {Configure ? : null} + + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx new file mode 100644 index 0000000000000..d81e31ba69d6d --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/dom'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; +import { CustomFields } from '.'; +import * as i18n from './translations'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + + const props = { + disabled: false, + isLoading: false, + handleAddCustomField: jest.fn(), + handleDeleteCustomField: jest.fn(), + handleEditCustomField: jest.fn(), + customFields: [], + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + }); + + it('renders loading state correctly', () => { + appMockRender.render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(props.handleAddCustomField).toBeCalled(); + }); + + it('calls handleEditCustomField on edit option click', async () => { + appMockRender.render( + + ); + + userEvent.click( + screen.getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + expect(props.handleEditCustomField).toBeCalledWith(customFieldsConfigurationMock[0].key); + }); + + it('shows the experimental badge', () => { + appMockRender.render(); + + expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('shows error when custom fields reaches the limit', async () => { + const generatedMockCustomFields = []; + + for (let i = 0; i < 8; i++) { + generatedMockCustomFields.push({ + key: `field_key_${i + 1}`, + label: `My custom label ${i + 1}`, + type: CustomFieldTypes.TEXT, + required: false, + }); + } + const customFields = [...customFieldsConfigurationMock, ...generatedMockCustomFields]; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + await waitFor(() => { + expect(screen.getByText(i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE))); + expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.tsx new file mode 100644 index 0000000000000..b2fea5b7eee43 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import * as i18n from './translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; +import { CustomFieldsList } from './custom_fields_list'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; + +export interface Props { + customFields: CustomFieldsConfiguration; + disabled: boolean; + isLoading: boolean; + handleAddCustomField: () => void; + handleDeleteCustomField: (key: string) => void; + handleEditCustomField: (key: string) => void; +} +const CustomFieldsComponent: React.FC = ({ + disabled, + isLoading, + handleAddCustomField, + handleDeleteCustomField, + handleEditCustomField, + customFields, +}) => { + const { permissions } = useCasesContext(); + const canAddCustomFields = permissions.create && permissions.update; + const [error, setError] = useState(false); + + const onAddCustomField = useCallback(() => { + if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) { + setError(true); + return; + } + + handleAddCustomField(); + setError(false); + }, [handleAddCustomField, setError, customFields, error]); + + const onEditCustomField = useCallback( + (key: string) => { + setError(false); + handleEditCustomField(key); + }, + [setError, handleEditCustomField] + ); + + if (customFields.length < MAX_CUSTOM_FIELDS_PER_CASE && error) { + setError(false); + } + + return canAddCustomFields ? ( + + {i18n.TITLE} + + + + + } + description={

{i18n.DESCRIPTION}

} + data-test-subj="custom-fields-form-group" + > + + {customFields.length ? ( + <> + + {error ? ( + + + + {i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)} + + + + ) : null} + + ) : null} + + {!customFields.length ? ( + + + {i18n.NO_CUSTOM_FIELDS} + + + + ) : null} + + + + {i18n.ADD_CUSTOM_FIELD} + + + + +
+ ) : null; +}; +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/schema.tsx b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx new file mode 100644 index 0000000000000..622ff1cb1673a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from './translations'; +import type { CustomFieldTypes } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; + +const { emptyField, maxLengthField } = fieldValidators; + +export interface CustomFieldsConfigurationFormProps { + key: string; + label: string; + type: CustomFieldTypes; + options?: { + required?: boolean; + }; +} + +export const schema = { + key: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + ], + }, + label: { + label: i18n.FIELD_LABEL, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + { + validator: maxLengthField({ + length: MAX_CUSTOM_FIELD_LABEL_LENGTH, + message: i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH), + }), + }, + ], + }, + type: { + label: i18n.FIELD_TYPE, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/config.ts b/x-pack/plugins/cases/public/components/custom_fields/text/config.ts new file mode 100644 index 0000000000000..b318ebd1b3439 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/config.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; +import { MAX_LENGTH_ERROR, REQUIRED_FIELD } from '../translations'; + +const { emptyField } = fieldValidators; + +export const getTextFieldConfig = ({ + required, + label, +}: { + required: boolean; + label: string; +}): FieldConfig => { + const validators = []; + + if (required) { + validators.push({ + validator: emptyField(REQUIRED_FIELD(label)), + }); + } + + return { + validations: [ + ...validators, + { + validator: ({ value }) => { + if (value == null) { + return; + } + + if (value.length > MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) { + return { + message: MAX_LENGTH_ERROR(label, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH), + }; + } + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx new file mode 100644 index 0000000000000..4ca8cbc1e8663 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import * as i18n from '../translations'; +import { Configure } from './configure'; + +describe('Configure ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx new file mode 100644 index 0000000000000..5a03bcd046630 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import * as i18n from '../translations'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + return ( + <> + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts new file mode 100644 index 0000000000000..0b5a4966ee730 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { configureTextCustomFieldFactory } from './configure_text_field'; + +describe('configureTextCustomFieldFactory ', () => { + const builder = configureTextCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'text', + label: 'Text', + build: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts new file mode 100644 index 0000000000000..0081a2449a3f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CustomFieldFactory } from '../types'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureTextCustomFieldFactory: CustomFieldFactory = () => ({ + id: CustomFieldTypes.TEXT, + label: i18n.TEXT_LABEL, + build: () => ({ + Configure, + Edit, + View, + Create, + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx new file mode 100644 index 0000000000000..1d1768a7c1c7c --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; + +describe('Create ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customFieldConfiguration = customFieldsConfigurationMock[0]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + render( + + + + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('disables the text when loading', async () => { + render( + + + + ); + + expect( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveAttribute('disabled'); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + userEvent.type( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + 'this is a sample text!' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: 'this is a sample text!', + }, + }, + true + ); + }); + }); + + it('shows error when text is too long', async () => { + render( + + + + ); + + const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + sampleText + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when text is too long and field is optional', async () => { + render( + + + + ); + + const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + sampleText + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when text is required but is empty', async () => { + render( + + + + ); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + '' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText(`${customFieldConfiguration.label} is required.`) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('does not show error when text is not required but is empty', async () => { + render( + + + + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, true); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx new file mode 100644 index 0000000000000..195e55073dd71 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import { getTextFieldConfig } from './config'; + +const CreateComponent: CustomFieldType['Create'] = ({ + customFieldConfiguration, + isLoading, +}) => { + const { key, label, required } = customFieldConfiguration; + const config = getTextFieldConfig({ required, label }); + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx new file mode 100644 index 0000000000000..54cdce7e79289 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx @@ -0,0 +1,381 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Edit } from './edit'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[0] as CaseCustomFieldText; + const customFieldConfiguration = customFieldsConfigurationMock[0]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByTestId('case-text-custom-field-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')).toBeInTheDocument(); + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(screen.getByText('My text test value 1')).toBeInTheDocument(); + }); + + it('does not shows the edit button if the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('does not shows the edit button when loading', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('shows the loading spinner when loading', async () => { + render( + + + + ); + + expect(screen.getByTestId('case-text-custom-field-loading-test_key_1')).toBeInTheDocument(); + }); + + it('shows the no value text if the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument(); + }); + + it('shows the no value text if the the value is null', async () => { + render( + + + + ); + + expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument(); + }); + + it('does not show the value when the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument(); + }); + + it('does not show the value when the value is null', async () => { + render( + + + + ); + + expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument(); + }); + + it('does not show the form when the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-text-custom-field-cancel-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!'); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: ['My text test value 1!!!'], + }); + }); + }); + + it('sets the value to null if the text field is empty', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: null, + }); + }); + }); + + it('hides the form when clicking the cancel button', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + + expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1')); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('reset to initial value when canceling', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!'); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1')); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toHaveValue( + 'My text test value 1' + ); + }); + + it('shows validation error if the field is required', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect(screen.getByText('My test label 1 is required.')).toBeInTheDocument(); + }); + }); + + it('does not shows a validation error if the field is not required', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + expect(screen.queryByText('My test label 1 is required.')).not.toBeInTheDocument(); + }); + + it('shows validation error if the field is too long', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + userEvent.paste( + screen.getByTestId('case-text-custom-field-form-field-test_key_1'), + 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) + ); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the My test label 1 is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx new file mode 100644 index 0000000000000..1fa016c6a1c9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useForm, UseField, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CasesConfigurationUICustomField } from '../../../../common/ui'; +import type { CustomFieldType } from '../types'; +import { View } from './view'; +import { CANCEL, EDIT_CUSTOM_FIELDS_ARIA_LABEL, NO_CUSTOM_FIELD_SET, SAVE } from '../translations'; +import { getTextFieldConfig } from './config'; + +interface FormState { + isValid: boolean | undefined; + submit: FormHook<{ value: string }>['submit']; +} + +interface FormWrapper { + initialValue: string; + isLoading: boolean; + customFieldConfiguration: CasesConfigurationUICustomField; + onChange: (state: FormState) => void; +} + +const FormWrapperComponent: React.FC = ({ + initialValue, + customFieldConfiguration, + isLoading, + onChange, +}) => { + const { form } = useForm({ + defaultValue: { value: initialValue }, + }); + + const { submit, isValid: isFormValid } = form; + + useEffect(() => { + onChange({ isValid: isFormValid, submit }); + }, [isFormValid, onChange, submit]); + + const formFieldConfig = getTextFieldConfig({ + required: customFieldConfiguration.required, + label: customFieldConfiguration.label, + }); + + return ( +
+ + + ); +}; + +FormWrapperComponent.displayName = 'FormWrapper'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const [isEdit, setIsEdit] = useState(false); + + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: { value: '' } }), + }); + + const onEdit = () => { + setIsEdit(true); + }; + + const onCancel = () => { + setIsEdit(false); + }; + + const onSubmitCustomField = async () => { + const { isValid, data } = await formState.submit(); + + if (isValid) { + const value = isEmpty(data.value) ? null : [data.value]; + + onSubmit({ + ...customField, + key: customField?.key ?? customFieldConfiguration.key, + type: CustomFieldTypes.TEXT, + value, + }); + } + + setIsEdit(false); + }; + + const initialValue = customField?.value?.[0] ?? ''; + const title = customFieldConfiguration.label; + const isTextFieldValid = formState.isValid; + const isCustomFieldValueDefined = !isEmpty(customField?.value); + + return ( + <> + + + +

{title}

+
+
+ {isLoading && ( + + )} + {!isLoading && canUpdate && ( + + + + )} +
+ + + {!isCustomFieldValueDefined && !isEdit && ( +

{NO_CUSTOM_FIELD_SET(customFieldConfiguration.label)}

+ )} + {!isEdit && isCustomFieldValueDefined && ( + + + + )} + {isEdit && canUpdate && ( + + + + + + + + + {SAVE} + + + + + {CANCEL} + + + + + + )} +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx new file mode 100644 index 0000000000000..bfad8aeda8bf7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: ['My text test value'], + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByText('My text test value')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx new file mode 100644 index 0000000000000..fa58b8fa6f9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ customField }) => { + const value = customField?.value?.[0] ?? '-'; + + return {value}; +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx new file mode 100644 index 0000000000000..4ca8cbc1e8663 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import * as i18n from '../translations'; +import { Configure } from './configure'; + +describe('Configure ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx new file mode 100644 index 0000000000000..a363d8c35eb45 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import * as i18n from '../translations'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + return ( + <> + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts new file mode 100644 index 0000000000000..c32dac659adfe --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { configureToggleCustomFieldFactory } from './configure_toggle_field'; + +describe('configureToggleCustomFieldFactory ', () => { + const builder = configureToggleCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'toggle', + label: 'Toggle', + build: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts new file mode 100644 index 0000000000000..00f103fcfdd6a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CustomFieldFactory } from '../types'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureToggleCustomFieldFactory: CustomFieldFactory = () => ({ + id: CustomFieldTypes.TOGGLE, + label: i18n.TOGGLE_LABEL, + build: () => ({ + Configure, + Edit, + View, + Create, + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx new file mode 100644 index 0000000000000..3a09b3f5b17cb --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; + +describe('Create ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customFieldConfiguration = customFieldsConfigurationMock[1]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect( + screen.getByTestId(`${customFieldConfiguration.key}-toggle-create-custom-field`) + ).toBeInTheDocument(); + expect(screen.getByRole('switch')).not.toBeChecked(); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: true, + }, + }, + true + ); + }); + }); + + it('sets value to false by default', async () => { + render( + + + + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: false, + }, + }, + true + ); + }); + }); + + it('disables the toggle when loading', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx new file mode 100644 index 0000000000000..169d3f48d6710 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const CreateComponent: CustomFieldType['Create'] = ({ + customFieldConfiguration, + isLoading, +}) => { + const { key, label } = customFieldConfiguration; + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx new file mode 100644 index 0000000000000..1af31cf13dd54 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Edit } from './edit'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[1] as CaseCustomFieldToggle; + const customFieldConfiguration = customFieldsConfigurationMock[1]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(screen.getByTestId('case-toggle-custom-field-test_key_2')).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeChecked(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ ...customField, value: false }); + }); + }); + + it('disables the toggle if the user does not have permissions', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('disables the toggle when loading', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('sets the configuration key and the initial value if the custom field is undefined', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + key: customFieldConfiguration.key, + /** + * Initial value is false when the custom field is undefined. + * By clicking to the switch it is set to true + */ + value: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx new file mode 100644 index 0000000000000..1bb3677c2bccb --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const initialValue = Boolean(customField?.value); + const title = customFieldConfiguration.label; + + const { form } = useForm<{ value: boolean }>({ + defaultValue: { value: initialValue }, + }); + + const onSubmitCustomField = async () => { + const { isValid, data } = await form.submit(); + + if (isValid) { + onSubmit({ + ...customField, + key: customField?.key ?? customFieldConfiguration.key, + type: CustomFieldTypes.TOGGLE, + value: data.value, + }); + } + }; + + return ( + <> + + + +

{title}

+
+
+
+ + +
+ + +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx new file mode 100644 index 0000000000000..33836b6784990 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_1', + value: true, + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByTestId('toggle-custom-field-view-test_key_1')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx new file mode 100644 index 0000000000000..13af4b529fff5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiIcon } from '@elastic/eui'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ customField }) => { + const value = Boolean(customField?.value); + const iconType = value ? 'check' : 'empty'; + + return ( + + {value} + + ); +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts new file mode 100644 index 0000000000000..d0a68d81becc2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const TITLE = i18n.translate('xpack.cases.customFields.title', { + defaultMessage: 'Custom Fields', +}); + +export const DESCRIPTION = i18n.translate('xpack.cases.customFields.description', { + defaultMessage: 'Add more optional and required fields for customized case collaboration.', +}); + +export const NO_CUSTOM_FIELDS = i18n.translate('xpack.cases.customFields.noCustomFields', { + defaultMessage: 'You do not have any fields yet', +}); + +export const ADD_CUSTOM_FIELD = i18n.translate('xpack.cases.customFields.addCustomField', { + defaultMessage: 'Add field', +}); + +export const MAX_CUSTOM_FIELD_LIMIT = (maxCustomFields: number) => + i18n.translate('xpack.cases.customFields.maxCustomFieldLimit', { + values: { maxCustomFields }, + defaultMessage: 'Maximum number of {maxCustomFields} custom fields reached.', + }); + +export const SAVE_FIELD = i18n.translate('xpack.cases.customFields.saveField', { + defaultMessage: 'Save field', +}); + +export const FIELD_LABEL = i18n.translate('xpack.cases.customFields.fieldLabel', { + defaultMessage: 'Field label', +}); + +export const FIELD_LABEL_HELP_TEXT = i18n.translate('xpack.cases.customFields.fieldLabelHelpText', { + defaultMessage: '50 characters max', +}); + +export const TEXT_LABEL = i18n.translate('xpack.cases.customFields.textLabel', { + defaultMessage: 'Text', +}); + +export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel', { + defaultMessage: 'Toggle', +}); + +export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', { + defaultMessage: 'Field type', +}); + +export const FIELD_OPTIONS = i18n.translate('xpack.cases.customFields.fieldOptions', { + defaultMessage: 'Options', +}); + +export const FIELD_OPTION_REQUIRED = i18n.translate( + 'xpack.cases.customFields.fieldOptions.Required', + { + defaultMessage: 'Make this field required', + } +); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.customFields.requiredField', { + values: { fieldName }, + defaultMessage: '{fieldName} is required.', + }); + +export const EDIT_CUSTOM_FIELDS_ARIA_LABEL = (customFieldLabel: string) => + i18n.translate('xpack.cases.caseView.editCustomFieldsAriaLabel', { + values: { customFieldLabel }, + defaultMessage: 'click to edit {customFieldLabel}', + }); + +export const NO_CUSTOM_FIELD_SET = (customFieldLabel: string) => + i18n.translate('xpack.cases.caseView.noCustomFieldSet', { + values: { customFieldLabel }, + defaultMessage: 'No "{customFieldLabel}" added', + }); + +export const DELETE_FIELD_TITLE = (fieldName: string) => + i18n.translate('xpack.cases.customFields.deleteField', { + values: { fieldName }, + defaultMessage: 'Delete field "{fieldName}"?', + }); + +export const DELETE_FIELD_DESCRIPTION = i18n.translate( + 'xpack.cases.customFields.deleteFieldDescription', + { + defaultMessage: 'The field will be removed from all cases and data will be lost.', + } +); + +export const DELETE = i18n.translate('xpack.cases.customFields.fieldOptions.Delete', { + defaultMessage: 'Delete', +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts new file mode 100644 index 0000000000000..e4cddebe59821 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; +import type { CustomFieldTypes } from '../../../common/types/domain'; +import type { CasesConfigurationUICustomField, CaseUICustomField } from '../../containers/types'; + +export interface CustomFieldType { + Configure: React.FC; + View: React.FC<{ + customField?: T; + }>; + Edit: React.FC<{ + customField?: T; + customFieldConfiguration: CasesConfigurationUICustomField; + onSubmit: (customField: T) => void; + isLoading: boolean; + canUpdate: boolean; + }>; + Create: React.FC<{ + customFieldConfiguration: CasesConfigurationUICustomField; + isLoading: boolean; + }>; +} + +export type CustomFieldFactory = () => { + id: string; + label: string; + build: () => CustomFieldType; +}; + +export type CustomFieldBuilderMap = { + readonly [key in CustomFieldTypes]: CustomFieldFactory; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts new file mode 100644 index 0000000000000..6f89b633eed0a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addOrReplaceCustomField } from './utils'; +import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import type { CaseUICustomField } from '../../../common/ui'; + +describe('addOrReplaceCustomField ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds new custom field correctly', async () => { + const fieldToAdd: CaseUICustomField = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + value: ['my_test_value'], + }; + const res = addOrReplaceCustomField(customFieldsMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": Array [ + "My text test value 1", + ], + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": Array [ + "my_test_value", + ], + }, + ] + ` + ); + }); + + it('updates existing custom field correctly', async () => { + const fieldToUpdate = { + ...customFieldsMock[0], + field: { value: ['My text test value 1!!!'] }, + }; + + const res = addOrReplaceCustomField(customFieldsMock, fieldToUpdate as CaseUICustomField); + expect(res).toMatchInlineSnapshot( + [{ ...fieldToUpdate }, { ...customFieldsMock[1] }], + ` + Array [ + Object { + "field": Object { + "value": Array [ + "My text test value 1!!!", + ], + }, + "key": "test_key_1", + "type": "text", + "value": Array [ + "My text test value 1", + ], + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + ] + ` + ); + }); + + it('adds new custom field configuration correctly', async () => { + const fieldToAdd = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + label: 'my_test_label', + required: true, + }; + const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsConfigurationMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "key": "test_key_2", + "label": "My test label 2", + "required": false, + "type": "toggle", + }, + Object { + "key": "my_test_key", + "label": "my_test_label", + "required": true, + "type": "text", + }, + ] + ` + ); + }); + + it('updates existing custom field config correctly', async () => { + const fieldToUpdate = { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!!`, + }; + + const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToUpdate); + expect(res).toMatchInlineSnapshot( + [{ ...fieldToUpdate }, { ...customFieldsConfigurationMock[1] }], + ` + Array [ + Object { + "key": "test_key_1", + "label": "My test label 1!!!", + "required": true, + "type": "text", + }, + Object { + "key": "test_key_2", + "label": "My test label 2", + "required": false, + "type": "toggle", + }, + ] + ` + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts new file mode 100644 index 0000000000000..18906f338fc42 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const addOrReplaceCustomField = ( + customFields: T[], + customFieldToAdd: T +): T[] => { + const foundCustomFieldIndex = customFields.findIndex( + (customField) => customField.key === customFieldToAdd.key + ); + + if (foundCustomFieldIndex === -1) { + return [...customFields, customFieldToAdd]; + } + + return customFields.map((customField) => { + if (customField.key !== customFieldToAdd.key) { + return customField; + } + + return customFieldToAdd; + }); +}; diff --git a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx new file mode 100644 index 0000000000000..a13aea90e8e6e --- /dev/null +++ b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/dom'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { ExperimentalBadge } from './experimental_badge'; + +describe('ExperimentalBadge', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders the experimental badge', () => { + appMockRenderer.render(); + + expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('renders the title correctly', () => { + appMockRenderer.render(); + + expect(screen.getByText('Technical preview')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx new file mode 100644 index 0000000000000..f20d821b95844 --- /dev/null +++ b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiBetaBadgeProps } from '@elastic/eui'; +import { EuiBetaBadge } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { EXPERIMENTAL_LABEL, EXPERIMENTAL_DESC } from '../../common/translations'; + +interface Props { + icon?: boolean; + size?: EuiBetaBadgeProps['size']; +} + +const ExperimentalBadgeComponent: React.FC = ({ icon = false, size = 's' }) => { + const props: EuiBetaBadgeProps = { + label: EXPERIMENTAL_LABEL, + size, + ...(icon && { iconType: 'beaker' }), + tooltipContent: EXPERIMENTAL_DESC, + tooltipPosition: 'bottom' as const, + 'data-test-subj': 'case-experimental-badge', + }; + + return ( + + ); +}; + +ExperimentalBadgeComponent.displayName = 'ExperimentalBadge'; + +export const ExperimentalBadge = React.memo(ExperimentalBadgeComponent); diff --git a/x-pack/plugins/cases/public/components/header_page/translations.ts b/x-pack/plugins/cases/public/components/header_page/translations.ts index 358f667bba367..b50db9b092ada 100644 --- a/x-pack/plugins/cases/public/components/header_page/translations.ts +++ b/x-pack/plugins/cases/public/components/header_page/translations.ts @@ -23,15 +23,6 @@ export const EDIT_TITLE_ARIA = (title: string) => defaultMessage: 'You can edit {title} by clicking', }); -export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.header.badge.experimentalLabel', { - defaultMessage: 'Technical preview', -}); - -export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.header.badge.experimentalDesc', { - defaultMessage: - 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', -}); - export const BETA_LABEL = i18n.translate('xpack.cases.header.badge.betaLabel', { defaultMessage: 'Beta', }); diff --git a/x-pack/plugins/cases/public/components/links/index.test.tsx b/x-pack/plugins/cases/public/components/links/index.test.tsx index 9f15ec7329b1f..81fd9217f19de 100644 --- a/x-pack/plugins/cases/public/components/links/index.test.tsx +++ b/x-pack/plugins/cases/public/components/links/index.test.tsx @@ -23,7 +23,6 @@ jest.mock('../../common/navigation/hooks'); describe('Configuration button', () => { let wrapper: ReactWrapper; const props: ConfigureCaseButtonProps = { - isDisabled: false, label: 'My label', msgTooltip: <>, showToolTip: false, diff --git a/x-pack/plugins/cases/public/components/links/index.tsx b/x-pack/plugins/cases/public/components/links/index.tsx index 2916aebd2d32f..f09a9f852a28f 100644 --- a/x-pack/plugins/cases/public/components/links/index.tsx +++ b/x-pack/plugins/cases/public/components/links/index.tsx @@ -66,7 +66,6 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; export interface ConfigureCaseButtonProps { - isDisabled: boolean; label: string; msgTooltip: JSX.Element; showToolTip: boolean; @@ -76,7 +75,6 @@ export interface ConfigureCaseButtonProps { // TODO: Fix this manually. Issue #123375 // eslint-disable-next-line react/display-name const ConfigureCaseButtonComponent: React.FC = ({ - isDisabled, label, msgTooltip, showToolTip, @@ -98,14 +96,14 @@ const ConfigureCaseButtonComponent: React.FC = ({ onClick={navigateToConfigureCasesClick} href={getConfigureCasesUrl()} iconType="controlsHorizontal" - isDisabled={isDisabled} + isDisabled={false} aria-label={label} data-test-subj="configure-case-button" > {label} ), - [label, isDisabled, navigateToConfigureCasesClick, getConfigureCasesUrl] + [label, navigateToConfigureCasesClick, getConfigureCasesUrl] ); return showToolTip ? ( diff --git a/x-pack/plugins/cases/public/components/user_actions/builder.tsx b/x-pack/plugins/cases/public/components/user_actions/builder.tsx index 25b4b849d871e..b0b3a9f7a7de9 100644 --- a/x-pack/plugins/cases/public/components/user_actions/builder.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/builder.tsx @@ -18,6 +18,7 @@ import { createTitleUserActionBuilder } from './title'; import { createCaseUserActionBuilder } from './create_case'; import type { UserActionBuilderMap } from './types'; import { createCategoryUserActionBuilder } from './category'; +import { createCustomFieldsUserActionBuilder } from './custom_fields/custom_fields'; export const builderMap: UserActionBuilderMap = { create_case: createCaseUserActionBuilder, @@ -32,4 +33,5 @@ export const builderMap: UserActionBuilderMap = { settings: createSettingsUserActionBuilder, assignees: createAssigneesUserActionBuilder, category: createCategoryUserActionBuilder, + customFields: createCustomFieldsUserActionBuilder, }; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 3576364f8ecd7..68bd8134c0e62 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -248,6 +248,7 @@ const getCreateCommentUserAction = ({ export const createCommentUserActionBuilder: UserActionBuilder = ({ appId, caseData, + casesConfiguration, userProfiles, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, @@ -293,6 +294,7 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({ const commentAction = getCreateCommentUserAction({ appId, caseData, + casesConfiguration, userProfiles, userAction: commentUserAction, externalReferenceAttachmentTypeRegistry, diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx new file mode 100644 index 0000000000000..b496fc28b133b --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { getUserAction } from '../../../containers/mock'; +import { TestProviders } from '../../../common/mock'; +import { createCustomFieldsUserActionBuilder } from './custom_fields'; +import { getMockBuilderArgs } from '../mock'; +import { + CustomFieldTypes, + UserActionActions, + UserActionTypes, +} from '../../../../common/types/domain'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/navigation/hooks'); + +describe('createCustomFieldsUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when a custom field is updated', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect( + screen.getByText('changed My test label 1 to "My text test value 1"') + ).toBeInTheDocument(); + }); + + it('renders correctly when a custom field is updated to an empty value: null', () => { + const userAction = getUserAction('customFields', UserActionActions.update, { + payload: { + customFields: [ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + ], + }, + }); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument(); + }); + + it('renders correctly when a custom field is updated to an empty value: empty array', () => { + const userAction = getUserAction('customFields', UserActionActions.update, { + payload: { + customFields: [ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: [], + }, + ], + }, + }); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument(); + }); + + it('renders correctly the label when the configuration is not found', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] }, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed Unknown to "My text test value 1"')).toBeInTheDocument(); + }); + + it('does not build any user actions if the payload is an empty array', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction: { + ...userAction, + type: UserActionTypes.customFields, + payload: { customFields: [] }, + }, + casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] }, + }); + + const createdUserAction = builder.build(); + expect(createdUserAction).toEqual([]); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx new file mode 100644 index 0000000000000..8f006ec157083 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CasesConfigurationUICustomField, CaseUICustomField } from '../../../../common/ui'; +import type { SnakeToCamelCase } from '../../../../common/types'; +import type { CustomFieldsUserAction } from '../../../../common/types/domain'; +import { createCommonUpdateUserActionBuilder } from '../common'; +import type { UserActionBuilder } from '../types'; +import * as i18n from '../translations'; + +const getLabelTitle = ( + customField: CaseUICustomField, + customFieldConfiguration?: CasesConfigurationUICustomField +) => { + const customFieldValue = customField.value; + const label = customFieldConfiguration?.label ?? customFieldConfiguration?.key ?? i18n.UNKNOWN; + + if ( + customFieldValue == null || + (Array.isArray(customFieldValue) && customFieldValue.length === 0) + ) { + return i18n.CHANGED_FIELD_TO_EMPTY(label); + } + + const value = Array.isArray(customFieldValue) ? customFieldValue[0] : customFieldValue; + + return `${i18n.CHANGED_FIELD.toLowerCase()} ${label} ${i18n.TO} "${value}"`; +}; + +export const createCustomFieldsUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, + userProfiles, + casesConfiguration, +}) => ({ + build: () => { + const customFieldsUserAction = userAction as SnakeToCamelCase; + + if (customFieldsUserAction.payload.customFields.length === 0) { + return []; + } + + const customField = customFieldsUserAction.payload.customFields[0]; + const customFieldConfiguration = casesConfiguration.customFields.find( + (configCustomField) => configCustomField.key === customField.key + ); + + const label = getLabelTitle(customField, customFieldConfiguration); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + userProfiles, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/mock.ts b/x-pack/plugins/cases/public/components/user_actions/mock.ts index 90d4223db4f7d..427a5caf36c81 100644 --- a/x-pack/plugins/cases/public/components/user_actions/mock.ts +++ b/x-pack/plugins/cases/public/components/user_actions/mock.ts @@ -13,6 +13,7 @@ import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; import { basicCase, getUserAction } from '../../containers/mock'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import type { UserActionBuilderArgs } from './types'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; export const getMockBuilderArgs = (): UserActionBuilderArgs => { const userAction = getUserAction('title', UserActionActions.update); @@ -63,6 +64,7 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => { externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, caseData: basicCase, + casesConfiguration: casesConfigurationsMock, comments: basicCase.comments, index: 0, alertData, diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index fc9c81f15bb2b..8e1377c4f0f28 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -15,6 +15,7 @@ import type { AttachmentUI, UseFetchAlertData, CaseUserActionsStats, + CasesConfigurationUI, } from '../../containers/types'; import type { AddCommentRefObject } from '../add_comment'; import type { UserActionMarkdownRefObject } from './markdown_form'; @@ -31,6 +32,7 @@ export interface UserActionTreeProps { userProfiles: Map; currentUserProfile: CurrentUserProfile; data: CaseUI; + casesConfiguration: CasesConfigurationUI; getRuleDetailsHref?: RuleDetailsNavigation['href']; actionsNavigation?: ActionsNavigation; onRuleDetailsClick?: RuleDetailsNavigation['onClick']; @@ -51,6 +53,7 @@ export type SupportedUserActionTypes = keyof Omit< export interface UserActionBuilderArgs { appId?: string; caseData: CaseUI; + casesConfiguration: CasesConfigurationUI; userProfiles: Map; currentUserProfile: CurrentUserProfile; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; diff --git a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx index a46a73c811c91..3b21d68ac43af 100644 --- a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx @@ -81,6 +81,7 @@ export const UserActionsList = React.memo( userProfiles, currentUserProfile, data: caseData, + casesConfiguration, getRuleDetailsHref, actionsNavigation, onRuleDetailsClick, @@ -129,6 +130,7 @@ export const UserActionsList = React.memo( const userActionBuilder = builder({ appId, caseData, + casesConfiguration, caseConnectors, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, @@ -156,14 +158,15 @@ export const UserActionsList = React.memo( return [...comments, ...userActionBuilder.build()]; }, []); }, [ + caseUserActions, appId, + caseData, + casesConfiguration, caseConnectors, - caseUserActions, - userProfiles, - currentUserProfile, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, - caseData, + userProfiles, + currentUserProfile, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 79a86e00cd7a6..c430181e3b947 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -20,6 +20,7 @@ import { parseURL, stringifyToURL, parseCaseUsers, + convertCustomFieldValue, } from './utils'; describe('Utils', () => { @@ -505,4 +506,30 @@ describe('Utils', () => { ]); }); }); + + describe('convertCustomFieldValue ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns array of string when value is string', async () => { + expect(convertCustomFieldValue('my text value')).toMatchInlineSnapshot(` + Array [ + "my text value", + ] + `); + }); + + it('returns null when value is empty string', async () => { + expect(convertCustomFieldValue('')).toMatchInlineSnapshot('null'); + }); + + it('returns value as it is when value is true', async () => { + expect(convertCustomFieldValue(true)).toMatchInlineSnapshot('true'); + }); + + it('returns value as it is when value is false', async () => { + expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1b0fbcd43ca1b..471f8ff544ed7 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -6,6 +6,7 @@ */ import type { IconType } from '@elastic/eui'; +import { isEmpty } from 'lodash'; import type { FieldConfig, ValidationConfig, @@ -225,3 +226,15 @@ export const parseCaseUsers = ({ return { userProfiles, reporterAsArray }; }; + +export const convertCustomFieldValue = (value: string | boolean) => { + let fieldValue = null; + + if (!isEmpty(value) && typeof value === 'string') { + fieldValue = [value]; + } else if (typeof value === 'boolean') { + fieldValue = value; + } + + return fieldValue; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts index bac2713c75135..550e9769c7ddc 100644 --- a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -7,9 +7,8 @@ import type { ActionConnector, ActionTypeConnector } from '../../../../common/types/domain'; -import type { ApiProps } from '../../types'; -import type { CaseConfigure } from '../types'; -import { caseConfigurationCamelCaseResponseMock } from '../mock'; +import type { ApiProps, CasesConfigurationUI } from '../../types'; +import { casesConfigurationsMock } from '../mock'; import { actionTypesMock, connectorsMock } from '../../../common/mock/connectors'; import type { ConfigurationPatchRequest, ConfigurationRequest } from '../../../../common/types/api'; @@ -17,18 +16,18 @@ export const getSupportedActionConnectors = async ({ signal, }: ApiProps): Promise => Promise.resolve(connectorsMock); -export const getCaseConfigure = async ({ signal }: ApiProps): Promise => - Promise.resolve(caseConfigurationCamelCaseResponseMock); +export const getCaseConfigure = async ({ signal }: ApiProps): Promise => + Promise.resolve(casesConfigurationsMock); export const postCaseConfigure = async ( caseConfiguration: ConfigurationRequest, signal: AbortSignal -): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); +): Promise => Promise.resolve(casesConfigurationsMock); export const patchCaseConfigure = async ( caseConfiguration: ConfigurationPatchRequest, signal: AbortSignal -): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); +): Promise => Promise.resolve(casesConfigurationsMock); export const fetchActionTypes = async ({ signal }: ApiProps): Promise => Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts index 844a23ab17f2b..435feee55c895 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -13,9 +13,9 @@ import { fetchActionTypes, } from './api'; import { - caseConfigurationMock, - caseConfigurationResposeMock, - caseConfigurationCamelCaseResponseMock, + caseConfigurationRequest, + caseConfigurationResponseMock, + casesConfigurationsMock, } from './mock'; import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; @@ -53,7 +53,7 @@ describe('Case Configuration API', () => { describe('fetch configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue([caseConfigurationResposeMock]); + fetchMock.mockResolvedValue([caseConfigurationResponseMock]); }); test('check url, method, signal', async () => { @@ -72,7 +72,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, owner: [SECURITY_SOLUTION_OWNER], }); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + expect(resp).toEqual(casesConfigurationsMock); }); test('return null on empty response', async () => { @@ -88,56 +88,46 @@ describe('Case Configuration API', () => { describe('create configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(caseConfigurationResposeMock); + fetchMock.mockResolvedValue(caseConfigurationResponseMock); }); test('check url, body, method, signal', async () => { - await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + await postCaseConfigure(caseConfigurationRequest); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { body: '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"owner":"securitySolution","closure_type":"close-by-user"}', method: 'POST', - signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + const resp = await postCaseConfigure(caseConfigurationRequest); + expect(resp).toEqual(casesConfigurationsMock); }); }); describe('update configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(caseConfigurationResposeMock); + fetchMock.mockResolvedValue(caseConfigurationResponseMock); }); test('check url, body, method, signal', async () => { - await patchCaseConfigure( - '123', - { - connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, - version: 'WzHJ12', - }, - abortCtrl.signal - ); + await patchCaseConfigure('123', { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure/123', { body: '{"connector":{"id":"456","name":"My Connector 2","type":".none","fields":null},"version":"WzHJ12"}', method: 'PATCH', - signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await patchCaseConfigure( - '123', - { - connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, - version: 'WzHJ12', - }, - abortCtrl.signal - ); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + const resp = await patchCaseConfigure('123', { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }); + expect(resp).toEqual(casesConfigurationsMock); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index f1d86cb090234..975cec77a3a1b 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -7,21 +7,26 @@ import { isEmpty } from 'lodash/fp'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { ConfigurationPatchRequest, ConfigurationRequest } from '../../../common/types/api'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { + ConfigurationPatchRequest, + ConfigurationRequest, + CreateConfigureResponse, + GetConfigureResponse, + UpdateConfigureResponse, +} from '../../../common/types/api'; import type { ActionConnector, ActionTypeConnector, Configuration, - Configurations, } from '../../../common/types/domain'; import { getAllConnectorTypesUrl } from '../../../common/utils/connectors_api'; import { getCaseConfigurationDetailsUrl } from '../../../common/api'; import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL } from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; import { convertToCamelCase, convertArrayToCamelCase } from '../../api/utils'; -import type { ApiProps } from '../types'; +import type { ApiProps, CasesConfigurationUI } from '../types'; import { decodeCaseConfigurationsResponse, decodeCaseConfigureResponse } from '../utils'; -import type { CaseConfigure } from './types'; export const getSupportedActionConnectors = async ({ signal, @@ -37,8 +42,8 @@ export const getSupportedActionConnectors = async ({ export const getCaseConfigure = async ({ signal, owner, -}: ApiProps & { owner: string[] }): Promise => { - const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { +}: ApiProps & { owner: string[] }): Promise => { + const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { method: 'GET', signal, query: { ...(owner.length > 0 ? { owner } : {}) }, @@ -47,7 +52,12 @@ export const getCaseConfigure = async ({ if (!isEmpty(response)) { const decodedConfigs = decodeCaseConfigurationsResponse(response); if (Array.isArray(decodedConfigs) && decodedConfigs.length > 0) { - return convertToCamelCase(decodedConfigs[0]); + const configuration = convertToCamelCase< + GetConfigureResponse[number], + SnakeToCamelCase + >(decodedConfigs[0]); + + return convertConfigureResponseToCasesConfigure(configuration); } } @@ -55,31 +65,42 @@ export const getCaseConfigure = async ({ }; export const postCaseConfigure = async ( - caseConfiguration: ConfigurationRequest, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { - method: 'POST', - body: JSON.stringify(caseConfiguration), - signal, - }); - return convertToCamelCase(decodeCaseConfigureResponse(response)); + caseConfiguration: ConfigurationRequest +): Promise => { + const response = await KibanaServices.get().http.fetch( + CASE_CONFIGURE_URL, + { + method: 'POST', + body: JSON.stringify(caseConfiguration), + } + ); + + const configuration = convertToCamelCase< + CreateConfigureResponse, + SnakeToCamelCase + >(decodeCaseConfigureResponse(response)); + + return convertConfigureResponseToCasesConfigure(configuration); }; export const patchCaseConfigure = async ( id: string, - caseConfiguration: ConfigurationPatchRequest, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch( + caseConfiguration: ConfigurationPatchRequest +): Promise => { + const response = await KibanaServices.get().http.fetch( getCaseConfigurationDetailsUrl(id), { method: 'PATCH', body: JSON.stringify(caseConfiguration), - signal, } ); - return convertToCamelCase(decodeCaseConfigureResponse(response)); + + const configuration = convertToCamelCase< + UpdateConfigureResponse, + SnakeToCamelCase + >(decodeCaseConfigureResponse(response)); + + return convertConfigureResponseToCasesConfigure(configuration); }; export const fetchActionTypes = async ({ signal }: ApiProps): Promise => { @@ -90,3 +111,11 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise +): CasesConfigurationUI => { + const { id, version, mappings, customFields, closureType, connector } = configuration; + + return { id, version, mappings, customFields, closureType, connector }; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index b01b6196a78b3..79e423e42db89 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -9,7 +9,9 @@ import type { ConfigurationRequest } from '../../../common/types/api'; import type { Configuration } from '../../../common/types/domain'; import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import type { CaseConfigure, CaseConnectorMapping } from './types'; +import type { CaseConnectorMapping } from './types'; +import type { CasesConfigurationUI } from '../types'; +import { customFieldsConfigurationMock } from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -29,7 +31,7 @@ export const mappings: CaseConnectorMapping[] = [ }, ]; -export const caseConfigurationResposeMock: Configuration = { +export const caseConfigurationResponseMock: Configuration = { id: '123', created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, @@ -46,9 +48,10 @@ export const caseConfigurationResposeMock: Configuration = { updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', + customFields: customFieldsConfigurationMock, }; -export const caseConfigurationMock: ConfigurationRequest = { +export const caseConfigurationRequest: ConfigurationRequest = { connector: { id: '123', name: 'My connector', @@ -59,10 +62,8 @@ export const caseConfigurationMock: ConfigurationRequest = { closure_type: 'close-by-user', }; -export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { +export const casesConfigurationsMock: CasesConfigurationUI = { id: '123', - createdAt: '2020-04-06T13:03:18.657Z', - createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, connector: { id: '123', name: 'My connector', @@ -70,10 +71,7 @@ export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { fields: null, }, closureType: 'close-by-pushing', - error: null, mappings: [], - updatedAt: '2020-04-06T14:03:18.657Z', - updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', - owner: SECURITY_SOLUTION_OWNER, + customFields: customFieldsConfigurationMock, }; diff --git a/x-pack/plugins/cases/public/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts index 4c9b7368d5bd0..5019b63c076b0 100644 --- a/x-pack/plugins/cases/public/containers/configure/types.ts +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -7,15 +7,14 @@ import type { ClosureType, - ConfigurationAttributes, ActionConnector, ActionTypeConnector, CaseConnector, ConnectorMappingTarget, ConnectorMappingSource, ConnectorMappingActionType, + CustomFieldsConfiguration, } from '../../../common/types/domain'; -import type { CaseUser } from '../types'; export type { ActionConnector, @@ -25,6 +24,7 @@ export type { ConnectorMappingSource, ConnectorMappingTarget, ClosureType, + CustomFieldsConfiguration, }; export interface CaseConnectorMapping { @@ -32,17 +32,3 @@ export interface CaseConnectorMapping { source: ConnectorMappingSource; target: string; } - -export interface CaseConfigure { - id: string; - closureType: ClosureType; - connector: ConfigurationAttributes['connector']; - createdAt: string; - createdBy: CaseUser; - error: string | null; - mappings: CaseConnectorMapping[]; - updatedAt: string; - updatedBy: CaseUser; - version: string; - owner: string; -} diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx deleted file mode 100644 index 8919c829585cf..0000000000000 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import type { ReturnUseCaseConfigure, ConnectorConfiguration } from './use_configure'; -import { initialState, useCaseConfigure } from './use_configure'; -import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; -import * as api from './api'; -import { ConnectorTypes } from '../../../common/types/domain'; -import { TestProviders } from '../../common/mock'; - -const mockErrorToast = jest.fn(); -const mockSuccessToast = jest.fn(); -jest.mock('./api'); -jest.mock('../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../common/lib/kibana'); - return { - ...originalModule, - useToasts: () => { - return { - addError: mockErrorToast, - addSuccess: mockSuccessToast, - }; - }, - }; -}); -const configuration: ConnectorConfiguration = { - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.none, - fields: null, - }, - closureType: 'close-by-pushing', -}; - -describe('useConfigure', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - test('init', async () => { - const { result } = renderHook(() => useCaseConfigure(), { - wrapper: ({ children }) => {children}, - }); - - await act(async () => - expect(result.current).toEqual({ - ...initialState, - refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setConnector: result.current.setConnector, - setClosureType: result.current.setClosureType, - setMappings: result.current.setMappings, - }) - ); - }); - - test('fetch case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(result.current).toEqual({ - ...initialState, - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - currentConfiguration: { - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - }, - mappings: [], - firstLoad: true, - loading: false, - persistCaseConfigure: result.current.persistCaseConfigure, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - version: caseConfigurationCamelCaseResponseMock.version, - id: caseConfigurationCamelCaseResponseMock.id, - }); - }); - }); - - test('refetch case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.refetchCaseConfigure(); - expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); - }); - }); - - test('correctly sets mappings', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(result.current.mappings).toEqual([]); - result.current.setMappings(mappings); - expect(result.current.mappings).toEqual(mappings); - }); - }); - - test('set isLoading to true when fetching case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.refetchCaseConfigure(); - - expect(result.current.loading).toBe(true); - }); - }); - - test('persist case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.persistCaseConfigure(configuration); - expect(result.current.persistLoading).toBeTruthy(); - }); - }); - - test('save case configuration - postCaseConfigure', async () => { - // When there is no version, a configuration is created. Otherwise is updated. - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - }) - ); - - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).not.toHaveBeenCalled(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current.connector.id).toEqual('123'); - await waitForNextUpdate(); - expect(result.current.connector.id).toEqual('456'); - }); - }); - - test('Displays error when present - getCaseConfigure', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - error: 'uh oh homeboy', - version: '', - }) - ); - - await act(async () => { - const { waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).toHaveBeenCalled(); - }); - }); - - test('Displays error when present - postCaseConfigure', async () => { - // When there is no version, a configuration is created. Otherwise is updated. - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - }) - ); - - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - error: 'uh oh homeboy', - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).not.toHaveBeenCalled(); - - result.current.persistCaseConfigure(configuration); - expect(mockErrorToast).not.toHaveBeenCalled(); - await waitForNextUpdate(); - expect(mockErrorToast).toHaveBeenCalled(); - }); - }); - - test('save case configuration - patchCaseConfigure', async () => { - const spyOnPatchCaseConfigure = jest.spyOn(api, 'patchCaseConfigure'); - spyOnPatchCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current.connector.id).toEqual('123'); - await waitForNextUpdate(); - expect(result.current.connector.id).toEqual('456'); - }); - }); - - test('unhappy path - fetch case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => { - throw new Error('Something went wrong'); - }); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - - await waitForNextUpdate(); - - expect(result.current).toEqual({ - ...initialState, - loading: false, - persistCaseConfigure: result.current.persistCaseConfigure, - persistLoading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - }); - }); - }); - - test('unhappy path - persist case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - id: '', - }) - ); - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => { - throw new Error('Something went wrong'); - }); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - - await waitForNextUpdate(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current).toEqual({ - ...initialState, - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - currentConfiguration: { - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - }, - firstLoad: true, - loading: false, - mappings: [], - persistCaseConfigure: result.current.persistCaseConfigure, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx deleted file mode 100644 index 12168b1b9c5c5..0000000000000 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx +++ /dev/null @@ -1,389 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useCallback, useReducer, useRef } from 'react'; -import { ConnectorTypes } from '../../../common/types/domain'; -import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; - -import * as i18n from './translations'; -import type { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; -import { useToasts } from '../../common/lib/kibana'; -import { useCasesContext } from '../../components/cases_context/use_cases_context'; - -export type ConnectorConfiguration = { connector: CaseConnector } & { - closureType: CaseConfigure['closureType']; -}; - -export interface State extends ConnectorConfiguration { - currentConfiguration: ConnectorConfiguration; - firstLoad: boolean; - loading: boolean; - mappings: CaseConnectorMapping[]; - persistLoading: boolean; - version: string; - id: string; -} -export type Action = - | { - type: 'setCurrentConfiguration'; - currentConfiguration: ConnectorConfiguration; - } - | { - type: 'setConnector'; - connector: CaseConnector; - } - | { - type: 'setLoading'; - payload: boolean; - } - | { - type: 'setFirstLoad'; - payload: boolean; - } - | { - type: 'setPersistLoading'; - payload: boolean; - } - | { - type: 'setVersion'; - payload: string; - } - | { - type: 'setID'; - payload: string; - } - | { - type: 'setClosureType'; - closureType: ClosureType; - } - | { - type: 'setMappings'; - mappings: CaseConnectorMapping[]; - }; - -export const configureCasesReducer = (state: State, action: Action) => { - switch (action.type) { - case 'setLoading': - return { - ...state, - loading: action.payload, - }; - case 'setFirstLoad': - return { - ...state, - firstLoad: action.payload, - }; - case 'setPersistLoading': - return { - ...state, - persistLoading: action.payload, - }; - case 'setVersion': - return { - ...state, - version: action.payload, - }; - case 'setID': - return { - ...state, - id: action.payload, - }; - case 'setCurrentConfiguration': { - return { - ...state, - currentConfiguration: { ...action.currentConfiguration }, - }; - } - case 'setConnector': { - return { - ...state, - connector: action.connector, - }; - } - case 'setClosureType': { - return { - ...state, - closureType: action.closureType, - }; - } - case 'setMappings': { - return { - ...state, - mappings: action.mappings, - }; - } - default: - return state; - } -}; - -export interface ReturnUseCaseConfigure extends State { - persistCaseConfigure: ({ connector, closureType }: ConnectorConfiguration) => unknown; - refetchCaseConfigure: () => void; - setClosureType: (closureType: ClosureType) => void; - setConnector: (connector: CaseConnector) => void; - setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; - setMappings: (newMapping: CaseConnectorMapping[]) => void; -} - -export const initialState: State = { - closureType: 'close-by-user', - connector: { - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }, - currentConfiguration: { - closureType: 'close-by-user', - connector: { - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }, - }, - firstLoad: false, - loading: true, - mappings: [], - persistLoading: false, - version: '', - id: '', -}; - -export const useCaseConfigure = (): ReturnUseCaseConfigure => { - const { owner } = useCasesContext(); - const [state, dispatch] = useReducer(configureCasesReducer, initialState); - const toasts = useToasts(); - const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { - dispatch({ - currentConfiguration: configuration, - type: 'setCurrentConfiguration', - }); - }, []); - - const setConnector = useCallback((connector: CaseConnector) => { - dispatch({ - connector, - type: 'setConnector', - }); - }, []); - - const setClosureType = useCallback((closureType: ClosureType) => { - dispatch({ - closureType, - type: 'setClosureType', - }); - }, []); - - const setMappings = useCallback((mappings: CaseConnectorMapping[]) => { - dispatch({ - mappings, - type: 'setMappings', - }); - }, []); - - const setLoading = useCallback((isLoading: boolean) => { - dispatch({ - payload: isLoading, - type: 'setLoading', - }); - }, []); - - const setFirstLoad = useCallback((isFirstLoad: boolean) => { - dispatch({ - payload: isFirstLoad, - type: 'setFirstLoad', - }); - }, []); - - const setPersistLoading = useCallback((isPersistLoading: boolean) => { - dispatch({ - payload: isPersistLoading, - type: 'setPersistLoading', - }); - }, []); - - const setVersion = useCallback((version: string) => { - dispatch({ - payload: version, - type: 'setVersion', - }); - }, []); - - const setID = useCallback((id: string) => { - dispatch({ - payload: id, - type: 'setID', - }); - }, []); - - const isCancelledRefetchRef = useRef(false); - const abortCtrlRefetchRef = useRef(new AbortController()); - - const isCancelledPersistRef = useRef(false); - const abortCtrlPersistRef = useRef(new AbortController()); - - const refetchCaseConfigure = useCallback(async () => { - try { - isCancelledRefetchRef.current = false; - abortCtrlRefetchRef.current.abort(); - abortCtrlRefetchRef.current = new AbortController(); - - setLoading(true); - const res = await getCaseConfigure({ - signal: abortCtrlRefetchRef.current.signal, - owner, - }); - - if (!isCancelledRefetchRef.current) { - if (res != null) { - setConnector(res.connector); - if (setClosureType != null) { - setClosureType(res.closureType); - } - setVersion(res.version); - setID(res.id); - setMappings(res.mappings); - - if (!state.firstLoad) { - setFirstLoad(true); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - } - if (res.error != null) { - toasts.addError(new Error(res.error), { - title: i18n.ERROR_TITLE, - }); - } - } - setLoading(false); - } - } catch (error) { - if (!isCancelledRefetchRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } - setLoading(false); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.firstLoad]); - - const persistCaseConfigure = useCallback( - async ({ connector, closureType }: ConnectorConfiguration) => { - try { - isCancelledPersistRef.current = false; - abortCtrlPersistRef.current.abort(); - abortCtrlPersistRef.current = new AbortController(); - setPersistLoading(true); - - const connectorObj = { - connector, - closure_type: closureType, - }; - - const res = - state.version.length === 0 - ? await postCaseConfigure( - // The first owner will be used for case creation - { ...connectorObj, owner: owner[0] }, - abortCtrlPersistRef.current.signal - ) - : await patchCaseConfigure( - state.id, - { - ...connectorObj, - version: state.version, - }, - abortCtrlPersistRef.current.signal - ); - if (!isCancelledPersistRef.current) { - setConnector(res.connector); - if (setClosureType) { - setClosureType(res.closureType); - } - setVersion(res.version); - setID(res.id); - setMappings(res.mappings); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - if (res.error != null) { - toasts.addError(new Error(res.error), { - title: i18n.ERROR_TITLE, - }); - } - toasts.addSuccess(i18n.SUCCESS_CONFIGURE); - setPersistLoading(false); - } - } catch (error) { - if (!isCancelledPersistRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { - title: i18n.ERROR_TITLE, - } - ); - } - setConnector(state.currentConfiguration.connector); - setPersistLoading(false); - } - } - }, - [ - setPersistLoading, - state.version, - state.id, - state.currentConfiguration.connector, - owner, - setConnector, - setClosureType, - setVersion, - setID, - setMappings, - setCurrentConfiguration, - toasts, - ] - ); - - useEffect(() => { - refetchCaseConfigure(); - return () => { - isCancelledRefetchRef.current = true; - abortCtrlRefetchRef.current.abort(); - isCancelledPersistRef.current = true; - abortCtrlPersistRef.current.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { - ...state, - refetchCaseConfigure, - persistCaseConfigure, - setCurrentConfiguration, - setConnector, - setClosureType, - setMappings, - }; -}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx new file mode 100644 index 0000000000000..1cb8685e26cbf --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useGetCaseConfiguration } from './use_get_case_configuration'; +import * as api from './api'; +import { waitFor } from '@testing-library/dom'; +import { useToasts } from '../../common/lib/kibana'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +jest.mock('./api'); +jest.mock('../../common/lib/kibana'); + +describe('Use get case configuration hook', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + + const { waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + await waitForNextUpdate(); + + expect(spy).toHaveBeenCalledWith({ + owner: ['securitySolution'], + signal: expect.any(AbortSignal), + }); + }); + + it('shows a toast error when the api return an error', async () => { + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addError }); + + const spy = jest.spyOn(api, 'getCaseConfigure').mockRejectedValue(new Error('error')); + + const { waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + await waitForNextUpdate(); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + owner: ['securitySolution'], + signal: expect.any(AbortSignal), + }); + + expect(addError).toHaveBeenCalled(); + }); + }); + + it('returns the default if the response is null', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + spy.mockResolvedValue(null); + + const { result, waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitForNextUpdate(); + + expect(result.current.data).toEqual({ + closureType: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + id: '', + mappings: [], + version: '', + }); + }); + + it('sets the initial data correctly', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + // @ts-expect-error: no need to define all properties + spy.mockResolvedValue({ id: 'my-new-configuration' }); + + const { result, waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitForNextUpdate(); + + /** + * Ensures that the initial data are returned + * before fetching + */ + // @ts-expect-error: data are defined + expect(result.all[0].data).toEqual({ + closureType: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + id: '', + mappings: [], + version: '', + }); + + /** + * The response after fetching + */ + // @ts-expect-error: data are defined + expect(result.all[1].data).toEqual({ id: 'my-new-configuration' }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx new file mode 100644 index 0000000000000..9cb839587d2a1 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { ConnectorTypes } from '../../../common'; +import * as i18n from './translations'; +import { getCaseConfigure } from './api'; +import type { ServerError } from '../../types'; +import { casesQueriesKeys } from '../constants'; +import type { CasesConfigurationUI } from '../types'; +import { useCasesToast } from '../../common/use_cases_toast'; +import { useCasesContext } from '../../components/cases_context/use_cases_context'; + +const initialConfiguration: CasesConfigurationUI = { + closureType: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + mappings: [], + version: '', + id: '', +}; + +const transformConfiguration = (data: CasesConfigurationUI | null): CasesConfigurationUI => { + if (data) { + return data; + } + + return initialConfiguration; +}; + +export const useGetCaseConfiguration = () => { + const { owner } = useCasesContext(); + const { showErrorToast } = useCasesToast(); + + return useQuery( + casesQueriesKeys.configuration(), + ({ signal }) => getCaseConfigure({ owner, signal }), + { + select: transformConfiguration, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + initialData: initialConfiguration, + } + ); +}; + +export type UseGetCaseConfiguration = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx new file mode 100644 index 0000000000000..b8a4ee93be1f8 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { usePersistConfiguration } from './use_persist_configuration'; +import * as api from './api'; +import { useToasts } from '../../common/lib/kibana'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { ConnectorTypes } from '../../../common'; +import { casesQueriesKeys } from '../constants'; + +jest.mock('./api'); +jest.mock('../../common/lib/kibana'); + +const useToastMock = useToasts as jest.Mock; + +describe('useCreateAttachments', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + useToastMock.mockReturnValue({ + addError, + addSuccess, + }); + + const request = { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + version: '', + id: '', + }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls postCaseConfigure when the id is empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, version: 'test' }); + }); + + await waitForNextUpdate(); + + expect(spyPatch).not.toHaveBeenCalled(); + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + }); + }); + + it('calls postCaseConfigure when the version is empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, id: 'test' }); + }); + + await waitForNextUpdate(); + + expect(spyPatch).not.toHaveBeenCalled(); + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + }); + }); + + it('calls patchCaseConfigure when the id and the version are not empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, id: 'test-id', version: 'test-version' }); + }); + + await waitForNextUpdate(); + + expect(spyPost).not.toHaveBeenCalled(); + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + version: 'test-version', + }); + }); + + it('invalidates the queries correctly', async () => { + const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration()); + }); + + it('shows the success toaster', async () => { + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(addSuccess).toHaveBeenCalled(); + }); + + it('shows a toast error when the api return an error', async () => { + jest + .spyOn(api, 'postCaseConfigure') + .mockRejectedValue(new Error('useCreateAttachments: Test error')); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx new file mode 100644 index 0000000000000..85615f07954ad --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { isEmpty } from 'lodash'; +import { postCaseConfigure, patchCaseConfigure } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../../types'; +import { useCasesToast } from '../../common/use_cases_toast'; +import { casesMutationsKeys, casesQueriesKeys } from '../constants'; +import { useCasesContext } from '../../components/cases_context/use_cases_context'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { ConfigurationRequest } from '../../../common/types/api'; + +type Request = Omit, 'owner'> & { + id: string; + version: string; +}; + +export const usePersistConfiguration = () => { + const queryClient = useQueryClient(); + const { owner } = useCasesContext(); + const { showErrorToast, showSuccessToast } = useCasesToast(); + + return useMutation( + ({ id, version, closureType, customFields, connector }: Request) => { + if (isEmpty(id) || isEmpty(version)) { + return postCaseConfigure({ + closure_type: closureType, + connector, + customFields: customFields ?? [], + owner: owner[0], + }); + } + + return patchCaseConfigure(id, { + version, + closure_type: closureType, + connector, + customFields: customFields ?? [], + }); + }, + { + mutationKey: casesMutationsKeys.persistCaseConfiguration, + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.configuration()); + showSuccessToast(i18n.SUCCESS_CONFIGURE); + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + } + ); +}; + +export type UsePersistConfiguration = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 172f7ae67f084..7c39e76590fb4 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -47,6 +47,7 @@ export const casesQueriesKeys = { categories: () => [...casesQueriesKeys.all, 'categories'] as const, alertFeatureIds: (alertRegistrationContexts: string[]) => [...casesQueriesKeys.alerts, 'features', alertRegistrationContexts] as const, + configuration: () => [...casesQueriesKeys.all, 'configuration'] as const, }; export const casesMutationsKeys = { @@ -59,4 +60,5 @@ export const casesMutationsKeys = { deleteComment: ['delete-comment'] as const, deleteFileAttachment: ['delete-file-attachment'] as const, bulkCreateAttachments: ['bulk-create-attachments'] as const, + persistCaseConfiguration: ['persist-case-configuration'] as const, }; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 920ce0ec8b283..7c4265462b21c 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -25,6 +25,7 @@ import { ConnectorTypes, AttachmentType, ExternalReferenceStorageType, + CustomFieldTypes, } from '../../common/types/domain'; import type { ActionLicense, CaseUI, CasesStatus, UserActionUI } from './types'; @@ -42,6 +43,8 @@ import type { CasesFindResponseUI, CasesUI, AttachmentUI, + CaseUICustomField, + CasesConfigurationUICustomField, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; @@ -247,6 +250,7 @@ export const basicCase: CaseUI = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [], }; export const basicFileMock: FileJSON = { @@ -364,6 +368,7 @@ export const mockCase: CaseUI = { }, assignees: [], category: null, + customFields: [], }; export const basicCasePost: CaseUI = { @@ -537,6 +542,7 @@ export const basicCaseSnake: Case = { updated_at: basicUpdatedAt, updated_by: elasticUserSnake, owner: SECURITY_SOLUTION_OWNER, + customFields: [], } as Case; export const caseWithAlertsSnake = { @@ -745,6 +751,15 @@ export const getUserAction = ( }, ...overrides, }; + case UserActionTypes.customFields: + return { + ...commonProperties, + type: UserActionTypes.customFields, + payload: { + customFields: customFieldsMock, + }, + ...overrides, + }; default: return { @@ -1131,3 +1146,13 @@ export const getCaseUsersMockResponse = (): CaseUsers => { ], }; }; + +export const customFieldsMock: CaseUICustomField[] = [ + { type: CustomFieldTypes.TEXT, key: 'test_key_1', value: ['My text test value 1'] }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, +]; + +export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = [ + { type: CustomFieldTypes.TEXT, key: 'test_key_1', label: 'My test label 1', required: true }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', label: 'My test label 2', required: false }, +]; diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index e2e0e6a7f8ec4..bbe91b1ad791e 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -102,13 +102,13 @@ export const createCasesSubClient = ( casesClientInternal: CasesClientInternal ): CasesSubClient => { const casesSubClient: CasesSubClient = { - create: (data: CasePostRequest) => create(data, clientArgs), + create: (data: CasePostRequest) => create(data, clientArgs, casesClient), find: (params: CasesFindRequest) => find(params, clientArgs), get: (params: GetParams) => get(params, clientArgs), resolve: (params: GetParams) => resolve(params, clientArgs), bulkGet: (params) => bulkGet(params, clientArgs), push: (params: PushParams) => push(params, clientArgs, casesClient), - update: (cases: CasesPatchRequest) => update(cases, clientArgs), + update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClient), delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs), diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index b53d12980c78a..ae31b77058e1e 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -11,12 +11,22 @@ import { MAX_LENGTH_PER_TAG, MAX_TITLE_LENGTH, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, } from '../../../common/constants'; +import type { CasePostRequest } from '../../../common'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { mockCases } from '../../mocks'; -import { createCasesClientMockArgs } from '../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { create } from './create'; -import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain'; +import { + CaseSeverity, + CaseStatuses, + ConnectorTypes, + CustomFieldTypes, +} from '../../../common/types/domain'; + +import type { CaseCustomFields } from '../../../common/types/domain'; +import { omit } from 'lodash'; describe('create', () => { const theCase = { @@ -36,6 +46,8 @@ describe('create', () => { }; const caseSO = mockCases[0]; + const casesClientMock = createCasesClientMock(); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); describe('Assignees', () => { const clientArgs = createCasesClientMockArgs(); @@ -46,7 +58,7 @@ describe('create', () => { }); it('notifies single assignees', async () => { - await create(theCase, clientArgs); + await create(theCase, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ assignees: theCase.assignees, @@ -55,7 +67,11 @@ describe('create', () => { }); it('notifies multiple assignees', async () => { - await create({ ...theCase, assignees: [{ uid: '1' }, { uid: '2' }] }, clientArgs); + await create( + { ...theCase, assignees: [{ uid: '1' }, { uid: '2' }] }, + clientArgs, + casesClientMock + ); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ assignees: [{ uid: '1' }, { uid: '2' }], @@ -64,7 +80,7 @@ describe('create', () => { }); it('does not notify when there are no assignees', async () => { - await create({ ...theCase, assignees: [] }, clientArgs); + await create({ ...theCase, assignees: [] }, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.notifyAssignees).not.toHaveBeenCalled(); }); @@ -75,7 +91,8 @@ describe('create', () => { ...theCase, assignees: [{ uid: '1' }, { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ @@ -87,7 +104,7 @@ describe('create', () => { it('should throw an error if the assignees array length is too long', async () => { const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' }); - await expect(create({ ...theCase, assignees }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, assignees }, clientArgs, casesClientMock)).rejects.toThrow( `Failed to create case: Error: The length of the field assignees is too long. Array must be of length <= ${MAX_ASSIGNEES_PER_CASE}.` ); }); @@ -104,7 +121,7 @@ describe('create', () => { it('should throw an error when an excess field exists', async () => { await expect( // @ts-expect-error foo is an invalid field - create({ ...theCase, foo: 'bar' }, clientArgs) + create({ ...theCase, foo: 'bar' }, clientArgs, casesClientMock) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to create case: Error: invalid keys \\"foo\\""` ); @@ -121,7 +138,7 @@ describe('create', () => { it(`should not throw an error if the title is non empty and less than ${MAX_TITLE_LENGTH} characters`, async () => { await expect( - create({ ...theCase, title: 'This is a test case!!' }, clientArgs) + create({ ...theCase, title: 'This is a test case!!' }, clientArgs, casesClientMock) ).resolves.not.toThrow(); }); @@ -133,7 +150,8 @@ describe('create', () => { title: 'This is a very long title with more than one hundred and sixty characters!! To confirm the maximum limit error thrown for more than one hundred and sixty characters!!', }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to create case: Error: The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` @@ -141,19 +159,19 @@ describe('create', () => { }); it('should throw an error if the title is an empty string', async () => { - await expect(create({ ...theCase, title: '' }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, title: '' }, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to create case: Error: The title field cannot be an empty string.' ); }); it('should throw an error if the title is a string with empty characters', async () => { - await expect(create({ ...theCase, title: ' ' }, clientArgs)).rejects.toThrow( - 'Failed to create case: Error: The title field cannot be an empty string.' - ); + await expect( + create({ ...theCase, title: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow('Failed to create case: Error: The title field cannot be an empty string.'); }); it('should trim title', async () => { - await create({ ...theCase, title: 'title with spaces ' }, clientArgs); + await create({ ...theCase, title: 'title with spaces ' }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,6 +188,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -188,7 +207,11 @@ describe('create', () => { it(`should not throw an error if the description is non empty and less than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { await expect( - create({ ...theCase, description: 'This is a test description!!' }, clientArgs) + create( + { ...theCase, description: 'This is a test description!!' }, + clientArgs, + casesClientMock + ) ).resolves.not.toThrow(); }); @@ -197,19 +220,25 @@ describe('create', () => { .fill('x') .toString(); - await expect(create({ ...theCase, description }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description }, clientArgs, casesClientMock) + ).rejects.toThrow( `Failed to create case: Error: The length of the description is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}.` ); }); it('should throw an error if the description is an empty string', async () => { - await expect(create({ ...theCase, description: '' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description: '' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The description field cannot be an empty string.' ); }); it('should throw an error if the description is a string with empty characters', async () => { - await expect(create({ ...theCase, description: ' ' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The description field cannot be an empty string.' ); }); @@ -217,7 +246,8 @@ describe('create', () => { it('should trim description', async () => { await create( { ...theCase, description: 'this is a description with spaces!! ' }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( @@ -235,6 +265,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -252,31 +283,35 @@ describe('create', () => { }); it('should not throw an error if the tags array is empty', async () => { - await expect(create({ ...theCase, tags: [] }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, tags: [] }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should not throw an error if the tags array has non empty string within limit', async () => { - await expect(create({ ...theCase, tags: ['abc'] }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, tags: ['abc'] }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should throw an error if the tags array length is too long', async () => { const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo'); - await expect(create({ ...theCase, tags }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, tags }, clientArgs, casesClientMock)).rejects.toThrow( `Failed to create case: Error: The length of the field tags is too long. Array must be of length <= ${MAX_TAGS_PER_CASE}.` ); }); it('should throw an error if the tags array has empty string', async () => { - await expect(create({ ...theCase, tags: [''] }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, tags: [''] }, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to create case: Error: The tag field cannot be an empty string.' ); }); it('should throw an error if the tags array has string with empty characters', async () => { - await expect(create({ ...theCase, tags: [' '] }, clientArgs)).rejects.toThrow( - 'Failed to create case: Error: The tag field cannot be an empty string.' - ); + await expect( + create({ ...theCase, tags: [' '] }, clientArgs, casesClientMock) + ).rejects.toThrow('Failed to create case: Error: The tag field cannot be an empty string.'); }); it('should throw an error if the tag length is too long', async () => { @@ -284,13 +319,15 @@ describe('create', () => { .fill('f') .toString(); - await expect(create({ ...theCase, tags: [tag] }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, tags: [tag] }, clientArgs, casesClientMock) + ).rejects.toThrow( `Failed to create case: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.` ); }); it('should trim tags', async () => { - await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs); + await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -307,6 +344,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -324,32 +362,39 @@ describe('create', () => { }); it('should not throw an error if the category is null', async () => { - await expect(create({ ...theCase, category: null }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, category: null }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should throw an error if the category length is too long', async () => { await expect( create( { ...theCase, category: 'A very long category with more than fifty characters!' }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow('Failed to create case: Error: The length of the category is too long.'); }); it('should throw an error if the category is an empty string', async () => { - await expect(create({ ...theCase, category: '' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, category: '' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The category field cannot be an empty string.,Invalid value "" supplied to "category"' ); }); it('should throw an error if the category is a string with empty characters', async () => { - await expect(create({ ...theCase, category: ' ' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, category: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The category field cannot be an empty string.,Invalid value " " supplied to "category"' ); }); it('should trim category', async () => { - await create({ ...theCase, category: 'reporting ' }, clientArgs); + await create({ ...theCase, category: 'reporting ' }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -365,6 +410,7 @@ describe('create', () => { external_service: null, duration: null, status: CaseStatuses.open, + customFields: [], }, id: expect.any(String), refresh: false, @@ -372,4 +418,376 @@ describe('create', () => { ); }); }); + + describe('Custom Fields', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + + const casesClient = createCasesClientMock(); + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + const theCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create customFields correctly', async () => { + await expect( + create( + { + ...theCase, + customFields: theCustomFields, + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + ...theCase, + closed_by: null, + closed_at: null, + category: null, + created_at: expect.any(String), + created_by: expect.any(Object), + updated_at: null, + updated_by: null, + external_service: null, + duration: null, + status: CaseStatuses.open, + customFields: theCustomFields, + }, + id: expect.any(String), + refresh: false, + }) + ); + }); + + it('should not throw an error and fill out missing customFields when they are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + await expect(create({ ...theCase }, clientArgs, casesClient)).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + ...theCase, + closed_by: null, + closed_at: null, + category: null, + created_at: expect.any(String), + created_by: expect.any(Object), + updated_at: null, + updated_by: null, + external_service: null, + duration: null, + status: CaseStatuses.open, + customFields: [ + { key: 'first_key', type: 'text', value: null }, + { key: 'second_key', type: 'toggle', value: null }, + ], + }, + id: expect.any(String), + refresh: false, + }) + ); + }); + + it('should throw an error when required customFields are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + await expect( + create({ ...theCase }, clientArgs, casesClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws error when the customFields array is too long', async () => { + await expect( + create( + { + ...theCase, + customFields: Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill(theCustomFields[0]), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: The length of the field customFields is too long. Array must be of length <= 10."` + ); + }); + + it('throws with duplicated customFields keys', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Invalid duplicated custom field keys in request: duplicated_key"` + ); + }); + + it('throws error when customFields keys are not present in configuration', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'missing_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Invalid custom field keys: missing_key"` + ); + }); + + it('throws error when required custom fields are missing', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws when the customField types do not match the configuration', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['foobar'], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: The following custom fields have the wrong type in the request: first_key,second_key"` + ); + }); + }); + + describe('User actions', () => { + const caseWithOnlyRequiredFields = omit(theCase, [ + 'assignees', + 'category', + 'severity', + 'customFields', + ]) as CasePostRequest; + + const caseWithOptionalFields: CasePostRequest = { + ...theCase, + category: 'My category', + severity: CaseSeverity.CRITICAL, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const casesClient = createCasesClientMock(); + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: caseWithOptionalFields.owner, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + it('should create a user action with defaults correctly', async () => { + await create(caseWithOnlyRequiredFields, clientArgs, casesClient); + + expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [], + category: null, + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: [], + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'low', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }); + }); + + it('should create a user action with optional fields set correctly', async () => { + await create(caseWithOptionalFields, clientArgs, casesClient); + + expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [{ uid: '1' }], + category: 'My category', + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: caseWithOptionalFields.customFields, + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'critical', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index f1ea52dcb45c9..d48eaa080dee8 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -16,18 +16,24 @@ import { decodeWithExcessOrThrow } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; import { flattenCaseSavedObject, transformNewCase } from '../../common/utils'; -import type { CasesClientArgs } from '..'; +import type { CasesClient, CasesClientArgs } from '..'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import { decodeOrThrow } from '../../../common/api/runtime_types'; import type { CasePostRequest } from '../../../common/types/api'; import { CasePostRequestRt } from '../../../common/types/api'; +import {} from '../utils'; +import { validateCustomFields } from './validators'; +import { fillMissingCustomFields } from './utils'; /** * Creates a new case. * - * @ignore */ -export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs): Promise => { +export const create = async ( + data: CasePostRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +): Promise => { const { services: { caseService, userActionService, licensingService, notificationService }, user, @@ -37,6 +43,15 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) try { const query = decodeWithExcessOrThrow(CasePostRequestRt)(data); + const configurations = await casesClient.configure.get({ owner: data.owner }); + const customFieldsConfiguration = configurations[0]?.customFields; + + const customFieldsValidationParams = { + requestCustomFields: data.customFields, + customFieldsConfiguration, + }; + + validateCustomFields(customFieldsValidationParams); const savedObjectID = SavedObjectsUtils.generateId(); @@ -62,21 +77,27 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) } /** - * Trim title, category, description and tags before saving to ES + * Trim title, category, description and tags + * and fill out missing custom fields + * before saving to ES */ - const trimmedQuery = { + const normalizedQuery = { ...query, title: query.title.trim(), description: query.description.trim(), category: query.category?.trim() ?? null, tags: query.tags?.map((tag) => tag.trim()) ?? [], + customFields: fillMissingCustomFields({ + customFields: query.customFields, + customFieldsConfiguration, + }), }; const newCase = await caseService.postNewCase({ attributes: transformNewCase({ user, - newCase: trimmedQuery, + newCase: normalizedQuery, }), id: savedObjectID, refresh: false, @@ -91,6 +112,7 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) severity: query.severity ?? CaseSeverity.LOW, assignees: query.assignees ?? [], category: query.category ?? null, + customFields: query.customFields ?? [], }, owner: newCase.attributes.owner, }); diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index f78e198ddeb52..a1427284c3c9a 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CustomFieldTypes } from '../../../common/types/domain'; import { MAX_CATEGORY_LENGTH, MAX_DESCRIPTION_LENGTH, @@ -14,9 +15,10 @@ import { MAX_CASES_TO_UPDATE, MAX_USER_ACTIONS_PER_CASE, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, } from '../../../common/constants'; import { mockCases } from '../../mocks'; -import { createCasesClientMockArgs } from '../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { update } from './update'; describe('update', () => { @@ -29,6 +31,8 @@ describe('update', () => { }, ], }; + const casesClientMock = createCasesClientMock(); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); describe('Assignees', () => { const clientArgs = createCasesClientMockArgs(); @@ -49,7 +53,7 @@ describe('update', () => { }); it('notifies an assignee', async () => { - await update(cases, clientArgs); + await update(cases, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ { @@ -76,7 +80,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"not-exists","version":"123"}]: Error: These cases not-exists do not exist. Please check you have the correct ids.' @@ -97,7 +102,7 @@ describe('update', () => { ], }); - await expect(update(cases, clientArgs)).rejects.toThrow( + await expect(update(cases, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: All update fields are identical to current version.' ); @@ -133,7 +138,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ @@ -174,7 +180,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([]); @@ -212,7 +219,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ @@ -249,7 +257,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); /** @@ -274,7 +283,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: invalid keys \\"foo\\""` @@ -295,7 +305,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the field assignees is too long. Array must be of length <= 10.' @@ -333,7 +344,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -350,7 +362,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the category is too long. The maximum length is ${MAX_CATEGORY_LENGTH}.,Invalid value \"A very long category with more than fifty characters!\" supplied to \"cases,category\"` @@ -369,7 +382,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The category field cannot be an empty string.,Invalid value "" supplied to "cases,category"' @@ -388,7 +402,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The category field cannot be an empty string.,Invalid value " " supplied to "cases,category"' @@ -406,7 +421,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -461,7 +477,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -479,7 +496,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` @@ -498,7 +516,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The title field cannot be an empty string.' @@ -517,7 +536,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The title field cannot be an empty string.' @@ -535,7 +555,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -590,7 +611,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -611,7 +633,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the description is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}.` @@ -630,7 +653,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The description field cannot be an empty string.' @@ -649,7 +673,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The description field cannot be an empty string.' @@ -667,7 +692,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -722,7 +748,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -743,7 +770,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -762,7 +790,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the field tags is too long. Array must be of length <= ${MAX_TAGS_PER_CASE}.` @@ -785,7 +814,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.` @@ -804,7 +834,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The tag field cannot be an empty string.' @@ -823,7 +854,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The tag field cannot be an empty string.' @@ -841,7 +873,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -866,7 +899,302 @@ describe('update', () => { }); }); + describe('Custom Fields', () => { + const clientArgs = createCasesClientMockArgs(); + const casesClient = createCasesClientMock(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: mockCases }); + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: mockCases[0].attributes.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + }); + + it('can update customFields', async () => { + const customFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ]; + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + { + caseId: mockCases[0].id, + version: mockCases[0].version, + originalCase: { + ...mockCases[0], + }, + updatedAttributes: { + customFields, + updated_at: expect.any(String), + updated_by: expect.any(Object), + }, + }, + ], + refresh: false, + }) + ); + }); + + it('fills out missing custom fields', async () => { + const customFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + ]; + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + { + caseId: mockCases[0].id, + version: mockCases[0].version, + originalCase: { + ...mockCases[0], + }, + updatedAttributes: { + customFields: [ + ...customFields, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ], + updated_at: expect.any(String), + updated_by: expect.any(Object), + }, + }, + ], + refresh: false, + }) + ); + }); + + it('throws error when the customFields array is too long', async () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: The length of the field customFields is too long. Array must be of length <= 10."` + ); + }); + + it('throws with duplicated customFields keys', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated custom field keys in request: duplicated_key"` + ); + }); + + it('throws when customFields keys are not present in configuration', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'missing_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid custom field keys: missing_key"` + ); + }); + + it('throws error when custom fields are missing', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws when the customField types dont match the configuration', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['foobar'], + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: The following custom fields have the wrong type in the request: first_key,second_key"` + ); + }); + }); + describe('Validation', () => { + const clientArgsMock = createCasesClientMockArgs(); + beforeEach(() => { jest.clearAllMocks(); }); @@ -881,7 +1209,8 @@ describe('update', () => { title: 'This is a test case!!', }), }, - createCasesClientMockArgs() + clientArgsMock, + casesClientMock ) ).rejects.toThrow( 'Error: The length of the field cases is too long. Array must be of length <= 100.' @@ -894,7 +1223,8 @@ describe('update', () => { { cases: [], }, - createCasesClientMockArgs() + clientArgsMock, + casesClientMock ) ).rejects.toThrow( 'Error: The length of the field cases is too short. Array must be of length >= 1.' @@ -902,14 +1232,12 @@ describe('update', () => { }); describe('Validate max user actions per page', () => { - const casesClient = createCasesClientMockArgs(); - beforeEach(() => { jest.clearAllMocks(); - casesClient.services.caseService.getCases.mockResolvedValue({ + clientArgsMock.services.caseService.getCases.mockResolvedValue({ saved_objects: [{ ...mockCases[0] }, { ...mockCases[1] }], }); - casesClient.services.caseService.getAllCaseComments.mockResolvedValue({ + clientArgsMock.services.caseService.getAllCaseComments.mockResolvedValue({ saved_objects: [], total: 0, per_page: 10, @@ -918,16 +1246,18 @@ describe('update', () => { }); it('passes validation if max user actions per case is not reached', async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1], }); - casesClient.services.caseService.patchCases.mockResolvedValue({ + clientArgsMock.services.caseService.patchCases.mockResolvedValue({ saved_objects: [{ ...mockCases[0] }], }); @@ -942,18 +1272,21 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).resolves.not.toThrow(); }); it(`throws an error when the user actions to be created will reach ${MAX_USER_ACTIONS_PER_CASE}`, async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1, 2, 3], }); @@ -968,7 +1301,8 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).rejects.toThrow( `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` @@ -976,13 +1310,15 @@ describe('update', () => { }); it('throws an error when trying to update multiple cases and one of them is expected to fail', async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, - [mockCases[1].id]: 0, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + [mockCases[1].id]: 0, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1, 2, 3], [mockCases[1].id]: [1], }); @@ -1004,7 +1340,8 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).rejects.toThrow( `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 64c0f170517fa..b2baff721302f 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -19,7 +19,7 @@ import { nodeBuilder } from '@kbn/es-query'; import type { AlertService, CasesService, CaseUserActionService } from '../../services'; import type { UpdateAlertStatusRequest } from '../alerts/types'; -import type { CasesClientArgs } from '..'; +import type { CasesClient, CasesClientArgs } from '..'; import type { OwnerEntity } from '../../authorization'; import type { PatchCasesArgs } from '../../services/cases/types'; import type { UserActionEvent, UserActionsDict } from '../../services/user_actions/types'; @@ -38,7 +38,12 @@ import { isCommentRequestTypeAlert, } from '../../common/utils'; import { arraysDifference, getCaseToUpdate } from '../utils'; -import { dedupAssignees, getClosedInfoForUpdate, getDurationForUpdate } from './utils'; +import { + dedupAssignees, + fillMissingCustomFields, + getClosedInfoForUpdate, + getDurationForUpdate, +} from './utils'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { LicensingService } from '../../services/licensing'; import type { CaseSavedObjectTransformed } from '../../common/types/case'; @@ -50,10 +55,12 @@ import type { User, CaseAssignees, AttachmentAttributes, + CustomFieldsConfiguration, } from '../../../common/types/domain'; import { CasesPatchRequestRt } from '../../../common/types/api'; import { decodeWithExcessOrThrow } from '../../../common/api'; import { CasesRt, CaseStatuses, AttachmentType } from '../../../common/types/domain'; +import { validateCustomFields } from './validators'; /** * Throws an error if any of the requests attempt to update the owner of a case. @@ -63,7 +70,7 @@ function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) { if (requestsUpdatingOwner.length > 0) { const ids = requestsUpdatingOwner.map(({ updateReq }) => updateReq.id); - throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); + throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); } } @@ -97,6 +104,26 @@ async function throwIfMaxUserActionsReached({ }); } +async function validateCustomFieldsInRequest({ + casesToUpdate, + customFieldsConfigurationMap, +}: { + casesToUpdate: UpdateRequestWithOriginalCase[]; + customFieldsConfigurationMap: Map; +}) { + casesToUpdate.forEach(({ updateReq, originalCase }) => { + if (updateReq.customFields) { + const owner = originalCase.attributes.owner; + const customFieldsConfiguration = customFieldsConfigurationMap.get(owner); + + validateCustomFields({ + requestCustomFields: updateReq.customFields, + customFieldsConfiguration, + }); + } + }); +} + /** * Throws an error if any of the requests attempt to update the assignees of the case * without the appropriate license @@ -272,7 +299,7 @@ function partitionPatchRequest( }; } -interface UpdateRequestWithOriginalCase { +export interface UpdateRequestWithOriginalCase { updateReq: CasePatchRequest; originalCase: CaseSavedObjectTransformed; } @@ -284,7 +311,8 @@ interface UpdateRequestWithOriginalCase { */ export const update = async ( cases: CasesPatchRequest, - clientArgs: CasesClientArgs + clientArgs: CasesClientArgs, + casesClient: CasesClient ): Promise => { const { services: { @@ -301,7 +329,6 @@ export const update = async ( try { const query = decodeWithExcessOrThrow(CasesPatchRequestRt)(cases); - const myCases = await caseService.getCases({ caseIds: query.cases.map((q) => q.id), }); @@ -342,6 +369,11 @@ export const update = async ( ); } + const configurations = await casesClient.configure.get({}); + const customFieldsConfigurationMap: Map = new Map( + configurations.map((conf) => [conf.owner, conf.customFields]) + ); + const casesToUpdate: UpdateRequestWithOriginalCase[] = query.cases.reduce( (acc: UpdateRequestWithOriginalCase[], updateCase) => { const originalCase = casesMap.get(updateCase.id); @@ -372,7 +404,13 @@ export const update = async ( throwIfUpdateOwner(casesToUpdate); throwIfUpdateAssigneesWithoutValidLicense(casesToUpdate, hasPlatinumLicense); - const patchCasesPayload = createPatchCasesPayload({ user, casesToUpdate }); + await validateCustomFieldsInRequest({ casesToUpdate, customFieldsConfigurationMap }); + + const patchCasesPayload = createPatchCasesPayload({ + user, + casesToUpdate, + customFieldsConfigurationMap, + }); const userActionsDict = userActionService.creator.buildUserActions({ updatedCases: patchCasesPayload, user, @@ -462,8 +500,9 @@ export const update = async ( } }; -const trimCaseAttributes = ( - updateCaseAttributes: Omit +const normalizeCaseAttributes = ( + updateCaseAttributes: Omit, + customFieldsConfiguration?: CustomFieldsConfiguration ) => { let trimmedAttributes = { ...updateCaseAttributes }; @@ -489,15 +528,27 @@ const trimCaseAttributes = ( }; } + if (updateCaseAttributes.customFields) { + trimmedAttributes = { + ...trimmedAttributes, + customFields: fillMissingCustomFields({ + customFields: updateCaseAttributes.customFields, + customFieldsConfiguration, + }), + }; + } + return trimmedAttributes; }; const createPatchCasesPayload = ({ casesToUpdate, user, + customFieldsConfigurationMap, }: { casesToUpdate: UpdateRequestWithOriginalCase[]; user: User; + customFieldsConfigurationMap: Map; }): PatchCasesArgs => { const updatedDt = new Date().toISOString(); @@ -508,7 +559,10 @@ const createPatchCasesPayload = ({ const dedupedAssignees = dedupAssignees(assignees); - const trimmedCaseAttributes = trimCaseAttributes(updateCaseAttributes); + const trimmedCaseAttributes = normalizeCaseAttributes( + updateCaseAttributes, + customFieldsConfigurationMap.get(originalCase.attributes.owner) + ); return { caseId, diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index b6f2bafe24f07..c16303c9c8f53 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -27,8 +27,10 @@ import { mapCaseFieldsToExternalSystemFields, formatComments, addKibanaInformationToDescription, + fillMissingCustomFields, } from './utils'; -import { CaseStatuses, UserActionActions } from '../../../common/types/domain'; +import type { CaseCustomFields } from '../../../common/types/domain'; +import { CaseStatuses, CustomFieldTypes, UserActionActions } from '../../../common/types/domain'; import { flattenCaseSavedObject } from '../../common/utils'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { casesConnectors } from '../../connectors'; @@ -1341,4 +1343,128 @@ describe('utils', () => { ).toEqual(userProfiles[0].user.username); }); }); + + describe('fillMissingCustomFields', () => { + const customFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ]; + + const customFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ]; + + it('adds missing custom fields correctly', () => { + expect( + fillMissingCustomFields({ + customFields, + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]); + }); + + it('does not set to null custom fields that exists', () => { + expect( + fillMissingCustomFields({ + customFields: [ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('returns all custom fields if they are more than the configuration', () => { + expect( + fillMissingCustomFields({ + customFields: [ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('adds missing custom fields if the customFields is undefined', () => { + expect( + fillMissingCustomFields({ + customFieldsConfiguration, + }) + ).toEqual([ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]); + }); + + it('does not add missing fields if the customFieldsConfiguration is undefined', () => { + expect( + fillMissingCustomFields({ + customFields, + }) + ).toEqual(customFields); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 5c77986742d54..9e7f1ab73af20 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -19,11 +19,15 @@ import type { ConnectorMappings, ConnectorMappingSource, ConnectorMappingTarget, + CustomFieldsConfiguration, ExternalService, User, } from '../../../common/types/domain'; import { CaseStatuses, UserActionTypes, AttachmentType } from '../../../common/types/domain'; -import type { CaseUserActionsDeprecatedResponse } from '../../../common/types/api'; +import type { + CaseRequestCustomFields, + CaseUserActionsDeprecatedResponse, +} from '../../../common/types/api'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { isPushedUserAction } from '../../../common/utils/user_actions'; import type { CasesClientGetAlertsResponse } from '../alerts/types'; @@ -453,3 +457,26 @@ export const getUserProfiles = async ( return acc; }, new Map()); }; + +export const fillMissingCustomFields = ({ + customFields = [], + customFieldsConfiguration = [], +}: { + customFields?: CaseRequestCustomFields; + customFieldsConfiguration?: CustomFieldsConfiguration; +}): CaseRequestCustomFields => { + const customFieldsKeys = new Set(customFields.map((customField) => customField.key)); + const missingCustomFields: CaseRequestCustomFields = []; + + for (const confCustomField of customFieldsConfiguration) { + if (!customFieldsKeys.has(confCustomField.key)) { + missingCustomFields.push({ + key: confCustomField.key, + type: confCustomField.type, + value: null, + }); + } + } + + return [...customFields, ...missingCustomFields]; +}; diff --git a/x-pack/plugins/cases/server/client/cases/validators.test.ts b/x-pack/plugins/cases/server/client/cases/validators.test.ts new file mode 100644 index 0000000000000..6956440a25685 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/validators.test.ts @@ -0,0 +1,407 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CustomFieldsConfiguration, CaseCustomFields } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { + validateCustomFieldKeysAgainstConfiguration, + validateCustomFieldTypesInRequest, + validateRequiredCustomFields, +} from './validators'; + +describe('validators', () => { + describe('validateCustomFieldTypesInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields types in request match the configuration', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateCustomFieldTypesInRequest({ + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateCustomFieldTypesInRequest({})).not.toThrow(); + }); + + it('throws for a single invalid type', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: first_key"` + ); + }); + + it('throws for multiple custom fields with invalid types', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + value: ['abc'], + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: first_key,second_key,third_key"` + ); + }); + + it('throws if configuration is missing and request has custom fields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + }); + + describe('validateCustomFieldKeysAgainstConfiguration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields are in configuration', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT as const, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no configuration found but no custom fields are in request', () => { + expect(() => validateCustomFieldKeysAgainstConfiguration({})).not.toThrow(); + }); + + it('throws if there are invalid custom field keys', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid custom field keys: invalid_key"`); + }); + + it('throws if configuration is missing and request has custom fields', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + }); + + describe('validateRequiredCustomFields', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all required custom fields are in the request', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: true, + }, + ]; + + const requestCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + customFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if there are only optional custom fields in configuration', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ]; + + expect(() => + validateRequiredCustomFields({ + customFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateRequiredCustomFields({})).not.toThrow(); + }); + + it('throws if there are missing required custom fields', () => { + const requestCustomFields: CaseCustomFields = [ + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]; + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: true, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + customFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Missing required custom fields: first_key"`); + }); + + it('throws if configuration is missing and request has custom fields', () => { + const requestCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + + it('throws if configuration has required fields but request has no custom fields', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + ]; + expect(() => + validateRequiredCustomFields({ + customFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Missing required custom fields: first_key"`); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/validators.ts b/x-pack/plugins/cases/server/client/cases/validators.ts new file mode 100644 index 0000000000000..f33ac2ff4d087 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/validators.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { differenceWith, intersectionWith } from 'lodash'; +import Boom from '@hapi/boom'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import type { CaseRequestCustomFields } from '../../../common/types/api'; +import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; + +interface CustomFieldValidationParams { + requestCustomFields?: CaseRequestCustomFields; + customFieldsConfiguration?: CustomFieldsConfiguration; +} + +export const validateCustomFields = (params: CustomFieldValidationParams) => { + validateDuplicatedCustomFieldKeysInRequest(params); + validateCustomFieldKeysAgainstConfiguration(params); + validateRequiredCustomFields(params); + validateCustomFieldTypesInRequest(params); +}; + +/** + * Throws if the type doesn't match the configuration. + */ +export function validateCustomFieldTypesInRequest({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + let invalidCustomFieldKeys: string[] = []; + + const validCustomFields = intersectionWith( + customFieldsConfiguration, + requestCustomFields, + (requiredVal, requestedVal) => + requiredVal.key === requestedVal.key && requiredVal.type === requestedVal.type + ); + + if (requestCustomFields.length !== validCustomFields.length) { + invalidCustomFieldKeys = differenceWith( + requestCustomFields, + validCustomFields, + (requiredVal, requestedVal) => requiredVal.key === requestedVal.key + ).map((e) => e.key); + } + + if (invalidCustomFieldKeys.length) { + throw Boom.badRequest( + `The following custom fields have the wrong type in the request: ${invalidCustomFieldKeys}` + ); + } +} + +/** + * Throws if the key doesn't match the configuration or is missing + */ +export const validateCustomFieldKeysAgainstConfiguration = ({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) => { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return []; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + const invalidCustomFieldKeys = differenceWith( + requestCustomFields, + customFieldsConfiguration, + (requestVal, configurationVal) => requestVal.key === configurationVal.key + ).map((e) => e.key); + + if (invalidCustomFieldKeys.length) { + throw Boom.badRequest(`Invalid custom field keys: ${invalidCustomFieldKeys}`); + } +}; + +/** + * Returns a list of required custom fields missing from the request + */ +export const validateRequiredCustomFields = ({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) => { + if (customFieldsConfiguration === undefined) { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return; + } else { + throw Boom.badRequest('No custom fields configured.'); + } + } + + const requiredCustomFields = customFieldsConfiguration.filter( + (customField) => customField.required + ); + + const missingRequiredCustomFields = differenceWith( + requiredCustomFields, + requestCustomFields ?? [], + (requiredVal, requestedVal) => requiredVal.key === requestedVal.key + ).map((e) => e.key); + + if (missingRequiredCustomFields.length) { + throw Boom.badRequest(`Missing required custom fields: ${missingRequiredCustomFields}`); + } +}; diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index 60c95a20f9e09..c92b1f96fbc3a 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -7,10 +7,18 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; + import type { CasesClientArgs } from '../types'; -import { getConnectors, get, update } from './client'; + +import { getConnectors, get, update, create } from './client'; import { createCasesClientInternalMock, createCasesClientMockArgs } from '../mocks'; -import { MAX_SUPPORTED_CONNECTORS_RETURNED } from '../../../common/constants'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_SUPPORTED_CONNECTORS_RETURNED, +} from '../../../common/constants'; +import { ConnectorTypes } from '../../../common'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import type { ConfigurationRequest } from '../../../common/types/api'; describe('client', () => { const clientArgs = createCasesClientMockArgs(); @@ -251,5 +259,153 @@ describe('client', () => { update('test-id', { version: 'test-version', foo: 'bar' }, clientArgs, casesClientInternal) ).rejects.toThrow('invalid keys "foo"'); }); + + it(`throws when trying to update more than ${MAX_CUSTOM_FIELDS_PER_CASE} custom fields`, async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'foobar', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('throws when there are duplicated custom field keys in the request', async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: [ + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated custom field keys in request: duplicated_key' + ); + }); + + it('throws when trying to updated the type of a custom field', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid custom field types in request for the following keys: wrong_type_key' + ); + }); + }); + + describe('create', () => { + const baseRequest = { + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'securitySolutionFixture', + } as ConfigurationRequest; + + it(`throws when trying to create more than ${MAX_CUSTOM_FIELDS_PER_CASE} custom fields`, async () => { + await expect( + create( + { + ...baseRequest, + customFields: new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'foobar', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('throws when there are duplicated keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated custom field keys in request: duplicated_key' + ); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 94a51256467e6..1482ea501ca87 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -48,6 +48,8 @@ import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { decodeOrThrow } from '../../../common/api/runtime_types'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; +import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; +import { validateCustomFieldTypesInRequest } from './validators'; /** * Defines the internal helper functions. @@ -250,6 +252,8 @@ export async function update( try { const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); + validateDuplicatedCustomFieldKeysInRequest({ requestCustomFields: request.customFields }); + const { version, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ @@ -257,6 +261,11 @@ export async function update( configurationId, }); + validateCustomFieldTypesInRequest({ + requestCustomFields: request.customFields, + originalCustomFields: configuration.attributes.customFields, + }); + await authorization.ensureAuthorized({ operation: Operations.updateConfiguration, entities: [{ owner: configuration.attributes.owner, id: configuration.id }], @@ -339,7 +348,7 @@ export async function update( } } -async function create( +export async function create( configRequest: ConfigurationRequest, clientArgs: CasesClientArgs, casesClientInternal: CasesClientInternal @@ -356,6 +365,10 @@ async function create( const validatedConfigurationRequest = decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest); + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: validatedConfigurationRequest.customFields, + }); + let error = null; const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = @@ -428,6 +441,7 @@ async function create( unsecuredSavedObjectsClient, attributes: { ...validatedConfigurationRequest, + customFields: validatedConfigurationRequest.customFields ?? [], connector: validatedConfigurationRequest.connector, created_at: creationDate, created_by: user, diff --git a/x-pack/plugins/cases/server/client/configure/validators.test.ts b/x-pack/plugins/cases/server/client/configure/validators.test.ts new file mode 100644 index 0000000000000..3ef853f0d671d --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/validators.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CustomFieldTypes } from '../../../common/types/domain'; +import { validateCustomFieldTypesInRequest } from './validators'; + +describe('validators', () => { + describe('validateCustomFieldTypesInRequest', () => { + it('throws an error with the keys of customFields in request that have invalid types', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TEXT }, + ], + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid custom field types in request for the following keys: 1,2"` + ); + }); + + it('throws an error when not all custom field types are invalid', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid custom field types in request for the following keys: 1"` + ); + }); + + it('does not throw if the request has no customFields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if the current configuration has no customFields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TEXT }, + ], + originalCustomFields: [], + }) + ).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/configure/validators.ts b/x-pack/plugins/cases/server/client/configure/validators.ts new file mode 100644 index 0000000000000..36743d1720376 --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/validators.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import type { CustomFieldTypes } from '../../../common/types/domain'; + +/** + * Throws an error if the request tries to change the type of existing custom fields. + */ +export const validateCustomFieldTypesInRequest = ({ + requestCustomFields, + originalCustomFields, +}: { + requestCustomFields?: Array<{ key: string; type: CustomFieldTypes }>; + originalCustomFields: Array<{ key: string; type: CustomFieldTypes }>; +}) => { + if (!Array.isArray(requestCustomFields) || !originalCustomFields.length) { + return; + } + + const invalidFields: string[] = []; + + requestCustomFields.forEach((requestField) => { + const originalField = originalCustomFields.find((item) => item.key === requestField.key); + + if (originalField && originalField.type !== requestField.type) { + invalidFields.push(requestField.key); + } + }); + + if (invalidFields.length > 0) { + throw Boom.badRequest( + `Invalid custom field types in request for the following keys: ${invalidFields}` + ); + } +}; diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts new file mode 100644 index 0000000000000..8d6caa218f932 --- /dev/null +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateDuplicatedCustomFieldKeysInRequest } from './validators'; + +describe('validators', () => { + describe('validateDuplicatedCustomFieldKeysInRequest', () => { + it('returns customFields in request that have duplicated keys', () => { + expect(() => + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: [ + { + key: 'triplicated_key', + }, + { + key: 'triplicated_key', + }, + { + key: 'triplicated_key', + }, + { + key: 'duplicated_key', + }, + { + key: 'duplicated_key', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated custom field keys in request: triplicated_key,duplicated_key"` + ); + }); + + it('does not throw if no customFields in request have duplicated keys', () => { + expect(() => + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: [ + { + key: '1', + }, + { + key: '2', + }, + ], + }) + ).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts new file mode 100644 index 0000000000000..88b62640cee88 --- /dev/null +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; + +/** + * Throws an error if the request has custom fields with duplicated keys. + */ +export const validateDuplicatedCustomFieldKeysInRequest = ({ + requestCustomFields = [], +}: { + requestCustomFields?: Array<{ key: string }>; +}) => { + const uniqueKeys = new Set(); + const duplicatedKeys = new Set(); + + requestCustomFields.forEach((item) => { + if (uniqueKeys.has(item.key)) { + duplicatedKeys.add(item.key); + } else { + uniqueKeys.add(item.key); + } + }); + + if (duplicatedKeys.size > 0) { + throw Boom.badRequest( + `Invalid duplicated custom field keys in request: ${Array.from(duplicatedKeys.values())}` + ); + } +}; diff --git a/x-pack/plugins/cases/server/common/types/case.ts b/x-pack/plugins/cases/server/common/types/case.ts index e22acd381fbe6..00a86f1a71d56 100644 --- a/x-pack/plugins/cases/server/common/types/case.ts +++ b/x-pack/plugins/cases/server/common/types/case.ts @@ -48,8 +48,15 @@ export interface CasePersistedAttributes { updated_at: string | null; updated_by: User | null; category?: string | null; + customFields?: CasePersistedCustomFields; } +type CasePersistedCustomFields = Array<{ + key: string; + type: string; + value: null | unknown | unknown[]; +}>; + export type CaseTransformedAttributes = CaseAttributes; export const CaseTransformedAttributesRt = CaseAttributesRt; diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 7f615a9fd5e09..a591375a4d439 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -25,8 +25,16 @@ export interface ConfigurationPersistedAttributes { created_by: User; updated_at: string | null; updated_by: User | null; + customFields?: PersistedCustomFieldsConfiguration; } +type PersistedCustomFieldsConfiguration = Array<{ + key: string; + type: string; + label: string; + required: boolean; +}>; + export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject; diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 71848e170af03..a632200a7b0bc 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -38,7 +38,12 @@ import type { CaseConnector, UserCommentAttachmentPayload, } from '../../common/types/domain'; -import { ConnectorTypes, CaseSeverity, AttachmentType } from '../../common/types/domain'; +import { + ConnectorTypes, + CaseSeverity, + AttachmentType, + CustomFieldTypes, +} from '../../common/types/domain'; import type { AttachmentRequest } from '../../common/types/api'; import { createAlertRequests, @@ -83,6 +88,21 @@ function createCommentFindResponse( } describe('common utils', () => { + const connector: CaseConnector = { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const customFields = [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + ]; + describe('transformNewCase', () => { beforeAll(() => { jest.useFakeTimers(); @@ -93,13 +113,6 @@ describe('common utils', () => { jest.useRealTimers(); }); - const connector: CaseConnector = { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - it('transform correctly', () => { const myCase = { newCase: { ...newCase, connector }, @@ -134,6 +147,7 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], "description": "A description", "duration": null, "external_service": null, @@ -188,6 +202,7 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], "description": "A description", "duration": null, "external_service": null, @@ -246,6 +261,75 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], + "description": "A description", + "duration": null, + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with customFields provided', () => { + const myCase = { + newCase: { + ...newCase, + connector, + customFields, + }, + user: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], "description": "A description", "duration": null, "external_service": null, @@ -304,6 +388,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, @@ -346,6 +431,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie destroying data!", "duration": null, "external_service": null, @@ -392,6 +478,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -442,6 +529,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -476,6 +564,89 @@ describe('common utils', () => { } `); }); + + it('transforms correctly case with customFields', () => { + const casesMap = new Map(); + const theCase = { ...mockCases[0] }; + + theCase.attributes = { ...theCase.attributes, customFields }; + casesMap.set(theCase.id, flattenCaseSavedObject({ savedObject: theCase })); + + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, + page: 1, + perPage: 10, + total: casesMap.size, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 1, + } + `); + }); }); describe('flattenCaseSavedObject', () => { @@ -509,6 +680,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -567,6 +739,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -648,6 +821,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -704,6 +878,72 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); + }); + + it('flattens correctly with customFields', () => { + const theCase = { ...mockCases[0] }; + theCase.attributes = { ...theCase.attributes, customFields }; + + const res = flattenCaseSavedObject({ + savedObject: theCase, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index 94ad077e21bf2..6843e855f3ba8 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -87,6 +87,7 @@ export const transformNewCase = ({ updated_by: null, assignees: dedupAssignees(newCase.assignees) ?? [], category: newCase.category ?? null, + customFields: newCase.customFields ?? [], }); export const transformCases = ({ diff --git a/x-pack/plugins/cases/server/mocks.ts b/x-pack/plugins/cases/server/mocks.ts index 4a3e3db88c6f5..f05b9e9e1a292 100644 --- a/x-pack/plugins/cases/server/mocks.ts +++ b/x-pack/plugins/cases/server/mocks.ts @@ -162,6 +162,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -204,6 +205,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -246,6 +248,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -292,6 +295,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:17.947Z', diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts similarity index 77% rename from x-pack/plugins/cases/server/saved_object_types/cases.ts rename to x-pack/plugins/cases/server/saved_object_types/cases/cases.ts index 164984793dd2a..8e9160604a69d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts @@ -13,16 +13,18 @@ import type { SavedObjectsExportTransformContext, SavedObjectsType, } from '@kbn/core/server'; -import { CASE_SAVED_OBJECT } from '../../common/constants'; -import type { CasePersistedAttributes } from '../common/types/case'; -import { handleExport } from './import_export/export'; -import { caseMigrations } from './migrations'; +import { CASE_SAVED_OBJECT } from '../../../common/constants'; +import type { CasePersistedAttributes } from '../../common/types/case'; +import { handleExport } from '../import_export/export'; +import { caseMigrations } from '../migrations'; +import { modelVersion1 } from './model_versions'; export const createCaseSavedObjectType = ( coreSetup: CoreSetup, logger: Logger ): SavedObjectsType => ({ name: CASE_SAVED_OBJECT, + switchToModelVersionAt: '8.10.0', indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, hidden: true, namespaceType: 'multiple-isolated', @@ -191,9 +193,48 @@ export const createCaseSavedObjectType = ( category: { type: 'keyword', }, + customFields: { + type: 'nested', + properties: { + key: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + value: { + type: 'keyword', + fields: { + number: { + type: 'long', + ignore_malformed: true, + }, + boolean: { + type: 'boolean', + // @ts-expect-error: es types are not correct. ignore_malformed is supported. + ignore_malformed: true, + }, + string: { + type: 'text', + }, + date: { + type: 'date', + ignore_malformed: true, + }, + ip: { + type: 'ip', + ignore_malformed: true, + }, + }, + }, + }, + }, }, }, migrations: caseMigrations, + modelVersions: { + 1: modelVersion1, + }, management: { importableAndExportable: true, defaultSearchField: 'title', diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts new file mode 100644 index 0000000000000..8520fd9673d31 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { modelVersion1 } from './model_versions'; + +describe('Model versions', () => { + describe('1', () => { + it('returns the model version correctly', () => { + expect(modelVersion1).toMatchInlineSnapshot(` + Object { + "changes": Array [ + Object { + "addedMappings": Object { + "customFields": Object { + "properties": Object { + "key": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + "value": Object { + "fields": Object { + "boolean": Object { + "ignore_malformed": true, + "type": "boolean", + }, + "date": Object { + "ignore_malformed": true, + "type": "date", + }, + "ip": Object { + "ignore_malformed": true, + "type": "ip", + }, + "number": Object { + "ignore_malformed": true, + "type": "long", + }, + "string": Object { + "type": "text", + }, + }, + "type": "keyword", + }, + }, + "type": "nested", + }, + }, + "type": "mappings_addition", + }, + ], + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts new file mode 100644 index 0000000000000..56806e7dec607 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; + +/** + * Adds custom fields to the cases SO. + */ +export const modelVersion1: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + customFields: { + type: 'nested', + properties: { + key: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + value: { + type: 'keyword', + fields: { + number: { + type: 'long', + ignore_malformed: true, + }, + boolean: { + // @ts-expect-error: es types are not correct. ignore_malformed is supported. + ignore_malformed: true, + type: 'boolean', + }, + string: { + type: 'text', + }, + date: { + type: 'date', + ignore_malformed: true, + }, + ip: { + type: 'ip', + ignore_malformed: true, + }, + }, + }, + }, + }, + }, + }, + ], +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/index.ts b/x-pack/plugins/cases/server/saved_object_types/index.ts index 8acbc1fbbb5fc..a43e60c0a240b 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -export { createCaseSavedObjectType } from './cases'; +export { createCaseSavedObjectType } from './cases/cases'; export { caseConfigureSavedObjectType } from './configure'; export { createCaseCommentSavedObjectType } from './comments'; export { createCaseUserActionSavedObjectType } from './user_actions'; diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 3f7ab21017b78..712e22732b9ff 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -210,6 +210,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "owner": "securitySolution", @@ -721,6 +722,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -1086,15 +1088,15 @@ describe('CasesService', () => { }); expect(res.attributes).toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - } - `); + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); expect(res.references).toMatchInlineSnapshot(`Array []`); }); @@ -1110,10 +1112,10 @@ describe('CasesService', () => { }); expect(res.attributes).toMatchInlineSnapshot(` - Object { - "external_service": null, - } - `); + Object { + "external_service": null, + } + `); expect(res.references).toMatchInlineSnapshot(`Array []`); }); @@ -1142,10 +1144,10 @@ describe('CasesService', () => { }); expect(res).toMatchInlineSnapshot(` - Object { - "attributes": Object {}, - } - `); + Object { + "attributes": Object {}, + } + `); }); it('returns the default none connector when it cannot find the reference', async () => { @@ -1887,7 +1889,8 @@ describe('CasesService', () => { 'severity', 'connector', 'external_service', - 'category' + 'category', + 'customFields' ); describe('getCaseIdsByAlertId', () => { @@ -1983,6 +1986,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, @@ -2072,6 +2076,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2164,6 +2169,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2256,6 +2262,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2361,6 +2368,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2416,6 +2424,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2517,6 +2526,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { diff --git a/x-pack/plugins/cases/server/services/cases/transform.ts b/x-pack/plugins/cases/server/services/cases/transform.ts index 99fafaf80cdb3..10eb6fc292323 100644 --- a/x-pack/plugins/cases/server/services/cases/transform.ts +++ b/x-pack/plugins/cases/server/services/cases/transform.ts @@ -15,7 +15,7 @@ import type { } from '@kbn/core/server'; import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server'; import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import type { ExternalService } from '../../../common/types/domain'; +import type { CaseCustomFields, ExternalService } from '../../../common/types/domain'; import { CaseSeverity, CaseStatuses } from '../../../common/types/domain'; import { CONNECTOR_ID_REFERENCE_NAME, @@ -45,6 +45,7 @@ export function transformUpdateResponseToExternalModel( status, total_alerts, total_comments, + customFields, ...restUpdateAttributes } = updatedCase.attributes ?? @@ -77,6 +78,9 @@ export function transformUpdateResponseToExternalModel( ...(transformedConnector && { connector: transformedConnector }), // if externalService is null that means we intentionally updated it to null within ES so return that as a valid value ...(externalService !== undefined && { external_service: externalService }), + ...(customFields !== undefined && { + customFields: customFields as CaseTransformedAttributes['customFields'], + }), }, }; } @@ -174,6 +178,9 @@ export function transformSavedObjectToExternalModel( SEVERITY_ESMODEL_TO_EXTERNAL[caseSavedObjectAttributes.severity] ?? CaseSeverity.LOW; const status = STATUS_ESMODEL_TO_EXTERNAL[caseSavedObjectAttributes.status] ?? CaseStatuses.open; const category = !caseSavedObjectAttributes.category ? null : caseSavedObjectAttributes.category; + const customFields = !caseSavedObjectAttributes.customFields + ? [] + : (caseSavedObjectAttributes.customFields as CaseCustomFields); return { ...caseSavedObject, @@ -184,6 +191,7 @@ export function transformSavedObjectToExternalModel( connector, external_service: externalService, category, + customFields, }, }; } diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts index 8de0815613b2e..3be7e771c5e64 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -43,6 +43,7 @@ const basicConfigFields = { email: 'testemail@elastic.co', username: 'elastic', }, + customFields: [], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial => ({ @@ -171,6 +172,7 @@ describe('CaseConfigureService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "owner": "securitySolution", "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { @@ -441,6 +443,7 @@ describe('CaseConfigureService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "owner": "securitySolution", "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 6860f9edc940d..5f31403be8d3e 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -83,7 +83,6 @@ export class CaseConfigureService { }: FindCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to find all case configuration`); - const findResp = await unsecuredSavedObjectsClient.find({ ...options, // Get the latest configuration @@ -120,6 +119,7 @@ export class CaseConfigureService { this.log.debug(`Attempting to POST a new case configuration`); const decodedAttributes = decodeOrThrow(ConfigurationTransformedAttributesRt)(attributes); + const esConfigInfo = transformAttributesToESModel(decodedAttributes); const createdConfig = @@ -149,6 +149,7 @@ export class CaseConfigureService { this.log.debug(`Attempting to UPDATE case configuration ${configurationId}`); const decodedAttributes = decodeOrThrow(ConfigurationPartialAttributesRt)(updatedAttributes); + const esUpdateInfo = transformAttributesToESModel(decodedAttributes); const updatedConfiguration = @@ -223,12 +224,16 @@ function transformToExternalModel( }); const castedAttributes = configuration.attributes as ConfigurationTransformedAttributes; + const customFields = !configuration.attributes.customFields + ? [] + : (configuration.attributes.customFields as ConfigurationTransformedAttributes['customFields']); return { ...configuration, attributes: { ...castedAttributes, connector, + customFields, }, }; } diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts index 22158ef4d656e..022a868ee0d9b 100644 --- a/x-pack/plugins/cases/server/services/test_utils.ts +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -163,6 +163,7 @@ export const basicCaseFields: CaseAttributes = { owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }; export const createCaseSavedObjectResponse = ({ diff --git a/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts b/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts index ace74c86c9444..53a19dccd11bd 100644 --- a/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts +++ b/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts @@ -22,6 +22,7 @@ import type { BuilderDeps } from './types'; import { AssigneesUserActionBuilder } from './builders/assignees'; import { NoopUserActionBuilder } from './builders/noop'; import { CategoryUserActionBuilder } from './builders/category'; +import { CustomFieldsUserActionBuilder } from './builders/custom_fields'; const builderMap = { assignees: AssigneesUserActionBuilder, @@ -37,6 +38,7 @@ const builderMap = { severity: SeverityUserActionBuilder, settings: SettingsUserActionBuilder, delete_case: NoopUserActionBuilder, + customFields: CustomFieldsUserActionBuilder, }; export class BuilderFactory { diff --git a/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts new file mode 100644 index 0000000000000..9269d2507ef97 --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CustomFieldTypes, UserActionActions } from '../../../../common/types/domain'; +import { PersistableStateAttachmentTypeRegistry } from '../../../attachment_framework/persistable_state_registry'; +import type { UserActionParameters } from '../types'; +import { CustomFieldsUserActionBuilder } from './custom_fields'; + +describe('CustomFieldsUserActionBuilder', () => { + const persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(); + + const builderArgs: UserActionParameters<'customFields'> = { + action: 'update' as const, + caseId: 'test-id', + user: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'cases', + payload: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + }; + + let builder: CustomFieldsUserActionBuilder; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2022-01-09T22:00:00.000Z')); + }); + + beforeEach(() => { + jest.resetAllMocks(); + + builder = new CustomFieldsUserActionBuilder({ persistableStateAttachmentTypeRegistry }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('builds the action correctly', async () => { + const res = builder.build(builderArgs); + + expect(res).toMatchInlineSnapshot(` + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "test-id", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "cases", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "test-id", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + } + `); + }); + + it.each([ + [UserActionActions.add, 'added', 'to'], + [UserActionActions.update, 'changed', 'for'], + [UserActionActions.delete, 'deleted', 'from'], + ])('show the message correctly for action: %s', async (action, verb, preposition) => { + const res = builder.build({ ...builderArgs, action }); + + expect(res.eventDetails.getMessage('ua-id')).toBe( + `User ${verb} keys: [string_custom_field_1] ${preposition} case id: test-id - user action id: ua-id` + ); + }); +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts new file mode 100644 index 0000000000000..67873ac3f4e1a --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CASE_SAVED_OBJECT } from '../../../../common/constants'; +import type { UserActionAction } from '../../../../common/types/domain'; +import { UserActionActions, UserActionTypes } from '../../../../common/types/domain'; +import { UserActionBuilder } from '../abstract_builder'; +import type { EventDetails, UserActionParameters, UserActionEvent } from '../types'; + +export class CustomFieldsUserActionBuilder extends UserActionBuilder { + build(args: UserActionParameters<'customFields'>): UserActionEvent { + const action = args.action ?? UserActionActions.add; + + const soParams = this.buildCommonUserAction({ + ...args, + action, + valueKey: 'customFields', + value: args.payload.customFields, + type: UserActionTypes.customFields, + }); + + const keys = args.payload.customFields.map((customField) => customField.key); + const verbMessage = getVerbMessage(action, keys); + + const getMessage = (id?: string) => + `User ${verbMessage} case id: ${args.caseId} - user action id: ${id}`; + + const event: EventDetails = { + getMessage, + action, + descriptiveAction: `case_user_action_${action}_case_custom_fields`, + savedObjectId: args.caseId, + savedObjectType: CASE_SAVED_OBJECT, + }; + + return { + parameters: soParams, + eventDetails: event, + }; + } +} + +const getVerbMessage = (action: UserActionAction, keys: string[]) => { + const keysText = `keys: [${keys}]`; + + switch (action) { + case 'add': + return `added ${keysText} to`; + case 'delete': + return `deleted ${keysText} from`; + default: + return `changed ${keysText} for`; + } +}; diff --git a/x-pack/plugins/cases/server/services/user_actions/mocks.ts b/x-pack/plugins/cases/server/services/user_actions/mocks.ts index 41689cc785324..00de014c9b1c1 100644 --- a/x-pack/plugins/cases/server/services/user_actions/mocks.ts +++ b/x-pack/plugins/cases/server/services/user_actions/mocks.ts @@ -12,7 +12,13 @@ import { createCaseSavedObjectResponse } from '../test_utils'; import { transformSavedObjectToExternalModel } from '../cases/transform'; import { alertComment, comment } from '../../mocks'; import type { UserActionsDict } from './types'; -import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain'; +import { + CaseSeverity, + CaseStatuses, + ConnectorTypes, + CustomFieldTypes, +} from '../../../common/types/domain'; +import type { PatchCasesArgs } from '../cases/types'; export const casePayload: CasePostRequest = { title: 'Case SIR', @@ -141,6 +147,143 @@ export const patchTagsCasesRequest = { ], }; +const originalCasesWithCustomFields = [ + { + ...createCaseSavedObjectResponse({ + overrides: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['old value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + ], + }, + }), + id: '1', + }, +].map((so) => transformSavedObjectToExternalModel(so)); + +export const patchAddCustomFieldsToOriginalCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + originalCase: originalCases[0], + }, + ], +}; + +export const patchUpdateCustomFieldsCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['updated value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchUpdateResetCustomFieldsCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: null, + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['new custom field 2'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchNewCustomFieldConfAdded: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + { + key: 'string_custom_field_3', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchCustomFieldConfRemoved: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + export const attachments = [ { id: '1', attachment: { ...comment }, owner: SECURITY_SOLUTION_OWNER }, { id: '2', attachment: { ...alertComment }, owner: SECURITY_SOLUTION_OWNER }, diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts index e29bb63168ece..432295989fca0 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts @@ -26,8 +26,13 @@ import { patchAddRemoveAssigneesCasesRequest, patchAssigneesCasesRequest, patchCasesRequest, + patchAddCustomFieldsToOriginalCasesRequest, + patchUpdateCustomFieldsCasesRequest, patchRemoveAssigneesCasesRequest, patchTagsCasesRequest, + patchUpdateResetCustomFieldsCasesRequest, + patchNewCustomFieldConfAdded, + patchCustomFieldConfRemoved, } from '../mocks'; import { AttachmentType } from '../../../../common/types/domain'; @@ -218,5 +223,315 @@ describe('UserActionPersister', () => { }) ); }); + + describe('customFields', () => { + it('creates the correct user actions when adding a new custom field to a case without custom fields', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchAddCustomFieldsToOriginalCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('creates the correct user actions when updating an existing custom field', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchUpdateCustomFieldsCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "updated value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('creates the correct user actions when updating and resetting custom fields', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchUpdateResetCustomFieldsCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": null, + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_2", + "type": "text", + "value": Array [ + "new custom field 2", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('should create a user action only for the updated field and not for the added configuration', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchNewCustomFieldConfAdded, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "new value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('should create a user action only for the field that got updated and not for the removed configuration', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchCustomFieldConfRemoved, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "new value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts index 3b9c51e2df24b..1f2ba3dc8f046 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts @@ -9,6 +9,8 @@ import type { SavedObject, SavedObjectsBulkResponse } from '@kbn/core/server'; import { get, isEmpty } from 'lodash'; import type { CaseAssignees, + CaseCustomField, + CaseCustomFields, CaseUserProfile, UserActionAction, UserActionType, @@ -37,7 +39,7 @@ import type { UserActionEvent, UserActionsDict, } from '../types'; -import { isAssigneesArray, isStringArray } from '../type_guards'; +import { isAssigneesArray, isCustomFieldsArray, isStringArray } from '../type_guards'; import type { IndexRefresh } from '../../types'; import { UserActionAuditLogger } from '../audit_logger'; @@ -120,6 +122,12 @@ export class UserActionPersister { isStringArray(newValue) ) { return this.buildTagsUserActions({ ...params, originalValue, newValue }); + } else if ( + field === UserActionTypes.customFields && + isCustomFieldsArray(originalValue) && + isCustomFieldsArray(newValue) + ) { + return this.buildCustomFieldsUserActions({ ...params, originalValue, newValue }); } else if (isUserActionType(field) && newValue !== undefined) { const userActionBuilder = this.builderFactory.getBuilder(UserActionTypes[field]); const fieldUserAction = userActionBuilder?.build({ @@ -154,6 +162,42 @@ export class UserActionPersister { return this.buildAddDeleteUserActions(params, createPayload, UserActionTypes.tags); } + private buildCustomFieldsUserActions(params: TypedUserActionDiffedItems) { + const createPayload: CreatePayloadFunction< + CaseCustomField, + typeof UserActionTypes.customFields + > = (items: CaseCustomFields) => ({ customFields: items }); + + const { originalValue: originalCustomFields, newValue: newCustomFields } = params; + + const originalCustomFieldsKeys = new Set( + originalCustomFields.map((customField) => customField.key) + ); + + const compareValues = arraysDifference(originalCustomFields, newCustomFields); + + const updatedCustomFieldsUsersActions = compareValues?.addedItems + .filter((customField) => { + if (customField.value != null) { + return true; + } + + return originalCustomFieldsKeys.has(customField.key); + }) + .map((customField) => + this.buildUserAction({ + commonArgs: params, + actionType: UserActionTypes.customFields, + action: UserActionActions.update, + createPayload, + modifiedItems: [customField], + }) + ) + .filter((userAction): userAction is UserActionEvent => userAction != null); + + return [...(updatedCustomFieldsUsersActions ? updatedCustomFieldsUsersActions : [])]; + } + private buildAddDeleteUserActions( params: TypedUserActionDiffedItems, createPayload: CreatePayloadFunction, diff --git a/x-pack/plugins/cases/server/services/user_actions/type_guards.ts b/x-pack/plugins/cases/server/services/user_actions/type_guards.ts index 494550923ce8e..8cd11926b66a5 100644 --- a/x-pack/plugins/cases/server/services/user_actions/type_guards.ts +++ b/x-pack/plugins/cases/server/services/user_actions/type_guards.ts @@ -6,8 +6,8 @@ */ import { isString } from 'lodash'; -import type { CaseAssignees } from '../../../common/types/domain'; -import { CaseAssigneesRt } from '../../../common/types/domain'; +import type { CaseAssignees, CaseCustomFields } from '../../../common/types/domain'; +import { CaseAssigneesRt, CaseCustomFieldsRt } from '../../../common/types/domain'; export const isStringArray = (value: unknown): value is string[] => { return Array.isArray(value) && value.every((val) => isString(val)); @@ -16,3 +16,7 @@ export const isStringArray = (value: unknown): value is string[] => { export const isAssigneesArray = (value: unknown): value is CaseAssignees => { return CaseAssigneesRt.is(value); }; + +export const isCustomFieldsArray = (value: unknown): value is CaseCustomFields => { + return CaseCustomFieldsRt.is(value); +}; diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts index 657fc31d7275b..42ebb7e413582 100644 --- a/x-pack/plugins/cases/server/services/user_actions/types.ts +++ b/x-pack/plugins/cases/server/services/user_actions/types.ts @@ -26,6 +26,7 @@ import type { CaseStatuses, User, CaseAssignees, + CaseCustomFields, } from '../../../common/types/domain'; import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import type { @@ -92,6 +93,9 @@ export interface BuilderParameters { category: { parameters: { payload: { category: string | null } }; }; + customFields: { + parameters: { payload: { customFields: CaseCustomFields } }; + }; } export interface CreateUserAction { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ed5d0042c8030..61e5306384a53 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10776,8 +10776,6 @@ "xpack.cases.getCurrentUser.unknownUser": "Inconnu", "xpack.cases.header.badge.betaDesc": "Cette fonctionnalité est actuellement en version bêta. Si vous rencontrez des bugs ou si vous souhaitez apporter des commentaires, ouvrez un ticket de problème ou visitez notre forum de discussion.", "xpack.cases.header.badge.betaLabel": "Bêta", - "xpack.cases.header.badge.experimentalDesc": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", - "xpack.cases.header.badge.experimentalLabel": "Version d'évaluation technique", "xpack.cases.header.editableTitle.cancel": "Annuler", "xpack.cases.header.editableTitle.save": "Enregistrer", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "Ajouter une visualisation", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 07f770568b038..e5dcbb8b17af5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10791,8 +10791,6 @@ "xpack.cases.getCurrentUser.unknownUser": "不明", "xpack.cases.header.badge.betaDesc": "現在、この機能はベータです。不具合を見つけた場合やご意見がある場合、サポートに問い合わせるか、またはディスカッションフォーラムにご報告ください。", "xpack.cases.header.badge.betaLabel": "ベータ", - "xpack.cases.header.badge.experimentalDesc": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", - "xpack.cases.header.badge.experimentalLabel": "テクニカルプレビュー", "xpack.cases.header.editableTitle.cancel": "キャンセル", "xpack.cases.header.editableTitle.save": "保存", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "ビジュアライゼーションを追加", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4369612f97b4a..fc12edb97a8e5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10791,8 +10791,6 @@ "xpack.cases.getCurrentUser.unknownUser": "未知", "xpack.cases.header.badge.betaDesc": "此功能当前为公测版。如果遇到任何错误或有任何反馈,请报告问题或访问我们的论坛。", "xpack.cases.header.badge.betaLabel": "公测版", - "xpack.cases.header.badge.experimentalDesc": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", - "xpack.cases.header.badge.experimentalLabel": "技术预览", "xpack.cases.header.editableTitle.cancel": "取消", "xpack.cases.header.editableTitle.save": "保存", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "添加可视化", diff --git a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts index b0f21f8dc386f..7b1b6d1b76c6c 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts @@ -6,10 +6,14 @@ */ import { CASE_CONFIGURE_URL } from '@kbn/cases-plugin/common/constants'; -import { ConfigurationRequest } from '@kbn/cases-plugin/common/types/api'; +import { + ConfigurationPatchRequest, + ConfigurationRequest, +} from '@kbn/cases-plugin/common/types/api'; import { CaseConnector, Configuration, + Configurations, ConnectorTypes, } from '@kbn/cases-plugin/common/types/domain'; import type SuperTest from 'supertest'; @@ -38,6 +42,7 @@ export const getConfigurationRequest = ({ } as CaseConnector, closure_type: 'close-by-user', owner: 'securitySolutionFixture', + customFields: [], ...overrides, }; }; @@ -49,6 +54,7 @@ export const getConfigurationOutput = (update = false, overwrite = {}): Partial< mappings: [], created_by: { email: null, full_name: null, username: 'elastic' }, updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, + customFields: [], ...overwrite, }; }; @@ -72,3 +78,45 @@ export const createConfiguration = async ( return configuration; }; + +export const getConfiguration = async ({ + supertest, + query = { owner: 'securitySolutionFixture' }, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: configuration } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .query(query) + .expect(expectedHttpCode); + + return configuration; +}; + +export const updateConfiguration = async ( + supertest: SuperTest.SuperTest, + id: string, + req: ConfigurationPatchRequest, + expectedHttpCode: number = 200, + auth: { user: User; space: string | null } | null = { user: superUser, space: null }, + headers: Record = {} +): Promise => { + const apiCall = supertest.patch(`${getSpaceUrlPrefix(auth?.space)}${CASE_CONFIGURE_URL}/${id}`); + + setupAuth({ apiCall, headers, auth }); + + const { body: configuration } = await apiCall + .set('kbn-xsrf', 'true') + .set(headers) + .send(req) + .expect(expectedHttpCode); + + return configuration; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/api/index.ts b/x-pack/test/cases_api_integration/common/lib/api/index.ts index 6a539457849e6..361a00eadb518 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/index.ts @@ -18,7 +18,6 @@ import { CASES_URL, CASE_COMMENT_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, - CASE_CONFIGURE_URL, CASE_REPORTERS_URL, CASE_SAVED_OBJECT, CASE_STATUS_URL, @@ -30,13 +29,10 @@ import { import { CaseMetricsFeature } from '@kbn/cases-plugin/common'; import type { SingleCaseMetricsResponse, CasesMetricsResponse } from '@kbn/cases-plugin/common'; import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/types'; -import { ActionResult } from '@kbn/actions-plugin/server/types'; import { CasePersistedAttributes } from '@kbn/cases-plugin/server/common/types/case'; import type { SavedObjectsRawDocSource } from '@kbn/core/server'; import type { ConfigurationPersistedAttributes } from '@kbn/cases-plugin/server/common/types/configure'; import { - Configurations, - Configuration, ConnectorMappingsAttributes, Case, Cases, @@ -49,7 +45,6 @@ import { CasesFindResponse, CasesPatchRequest, CasesStatusResponse, - ConfigurationPatchRequest, GetRelatedCasesByAlertResponse, } from '@kbn/cases-plugin/common/types/api'; import { User } from '../authentication/types'; @@ -441,52 +436,6 @@ export const updateCase = async ({ return cases; }; -export const getConfiguration = async ({ - supertest, - query = { owner: 'securitySolutionFixture' }, - expectedHttpCode = 200, - auth = { user: superUser, space: null }, -}: { - supertest: SuperTest.SuperTest; - query?: Record; - expectedHttpCode?: number; - auth?: { user: User; space: string | null }; -}): Promise => { - const { body: configuration } = await supertest - .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) - .auth(auth.user.username, auth.user.password) - .set('kbn-xsrf', 'true') - .query(query) - .expect(expectedHttpCode); - - return configuration; -}; - -export type CreateConnectorResponse = Omit & { - connector_type_id: string; -}; - -export const updateConfiguration = async ( - supertest: SuperTest.SuperTest, - id: string, - req: ConfigurationPatchRequest, - expectedHttpCode: number = 200, - auth: { user: User; space: string | null } | null = { user: superUser, space: null }, - headers: Record = {} -): Promise => { - const apiCall = supertest.patch(`${getSpaceUrlPrefix(auth?.space)}${CASE_CONFIGURE_URL}/${id}`); - - setupAuth({ apiCall, headers, auth }); - - const { body: configuration } = await apiCall - .set('kbn-xsrf', 'true') - .set(headers) - .send(req) - .expect(expectedHttpCode); - - return configuration; -}; - export const getAllCasesStatuses = async ({ supertest, expectedHttpCode = 200, diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index b18ea72e02809..969df10a53d1d 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -174,6 +174,7 @@ export const postCaseResp = ( status: CaseStatuses.open, updated_by: null, category: null, + customFields: [], }); interface CommentRequestWithID { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts index 3f5d757124e4e..060b6463c0451 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts @@ -79,7 +79,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(objects).to.have.length(4); - const expectedCaseRequest = { ...caseRequest, category: null }; // added default value + const expectedCaseRequest = { ...caseRequest, category: null, customFields: [] }; // added default value expectExportToHaveCaseSavedObject(objects, expectedCaseRequest); expectExportToHaveUserActions(objects, expectedCaseRequest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index 0aa6d673ec55c..576fc11cdf51b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -15,6 +15,7 @@ import { CaseSeverity, CaseStatuses, ConnectorTypes, + CustomFieldTypes, } from '@kbn/cases-plugin/common/types/domain'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -37,6 +38,8 @@ import { calculateDuration, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { createSignalsIndex, @@ -74,6 +77,21 @@ export default ({ getService }: FtrProviderContext): void => { describe('happy path', () => { it('should patch a case', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); const postedCase = await createCase(supertest, postCaseReq); const patchedCases = await updateCase({ supertest, @@ -91,12 +109,19 @@ export default ({ getService }: FtrProviderContext): void => { const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); expect(data).to.eql({ ...postCaseResp(), + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], title: 'new title', updated_by: defaultUser, }); }); - it('should closes the case correctly', async () => { + it('should close the case correctly', async () => { const postedCase = await createCase(supertest, postCaseReq); const patchedCases = await updateCase({ supertest, @@ -288,6 +313,136 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('should patch a case with customFields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + ], + }, + }); + + expect(patchedCases[0].customFields).to.eql([ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('should fill out missing optional custom fields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }); + + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + ], + }, + }); + + expect(patchedCases[0].customFields).to.eql([ + { key: 'test_custom_field_2', type: 'toggle', value: true }, + { key: 'test_custom_field_1', type: 'text', value: null }, + ]); + }); + describe('duration', () => { it('updates the duration correctly when the case closes', async () => { const postedCase = await createCase(supertest, postCaseReq); @@ -817,6 +972,158 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('customFields', async () => { + it('400s when trying to patch with duplicated custom field keys', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch with a custom field key that does not exist', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'key_does_not_exist', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch a case with a missing required custom field', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TEXT, + value: ['hello'], + }, + ], + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch a case with a custom field with the wrong type', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: false, + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + }); }); describe('alerts', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts index c32103ea51fef..6d152008e9074 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -8,15 +8,21 @@ import expect from '@kbn/expect'; import { CASES_URL } from '@kbn/cases-plugin/common/constants'; -import { CaseStatuses, CaseSeverity } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseStatuses, + CaseSeverity, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { ConnectorJiraTypeFields, ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; import { getPostCaseRequest, postCaseResp, defaultUser } from '../../../../common/lib/mock'; import { - deleteCasesByESQuery, + deleteAllCaseItems, createCase, removeServerGeneratedPropertiesFromCase, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { secOnly, @@ -37,7 +43,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('post_case', () => { afterEach(async () => { - await deleteCasesByESQuery(es); + await deleteAllCaseItems(es); }); describe('happy path', () => { @@ -155,6 +161,7 @@ export default ({ getService }: FtrProviderContext): void => { severity: CaseSeverity.LOW, assignees: [], category: null, + customFields: [], }, }); }); @@ -165,6 +172,103 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(postCaseResp()); }); + + it('should post a case with customFields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'valid_key_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'valid_key_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const res = await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'valid_key_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }) + ); + + expect(res.customFields).to.eql([ + { + key: 'valid_key_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('should fill out missing custom fields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'valid_key_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'valid_key_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const res = await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }) + ); + + expect(res.customFields).to.eql([ + { key: 'valid_key_2', type: 'toggle', value: true }, + { key: 'valid_key_1', type: 'text', value: null }, + ]); + }); }); describe('unhappy path', () => { @@ -324,6 +428,128 @@ export default ({ getService }: FtrProviderContext): void => { ); }); }); + + describe('customFields', async () => { + it('400s when trying to patch with duplicated custom field keys', async () => { + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with customField key that does not exist', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with a required custom field', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with a custom field with the wrong type', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: true, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }), + 400 + ); + }); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts index 042b9856fc45c..7679a3fbda9a7 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts @@ -18,7 +18,7 @@ import { getPostCaseRequest, } from '../../../../common/lib/mock'; import { - deleteCasesByESQuery, + deleteAllCaseItems, createCase, resolveCase, createComment, @@ -46,7 +46,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('resolve_case', () => { afterEach(async () => { - await deleteCasesByESQuery(es); + await deleteAllCaseItems(es); }); it('should resolve a case with no comments', async () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts index 94e414fa23f45..87c65ba8a47c9 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -47,12 +48,32 @@ export default ({ getService }: FtrProviderContext): void => { it('should return a configuration', async () => { await createConfiguration(supertest); - const configuration = await getConfiguration({ supertest }); + const configuration = await getConfiguration({ supertest }); const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput()); }); + it('should return a configuration with customFields', async () => { + const customFields = { + customFields: [ + { key: 'hello', label: 'text', type: CustomFieldTypes.TEXT, required: false }, + { key: 'goodbye', label: 'toggle', type: CustomFieldTypes.TOGGLE, required: true }, + ], + }; + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: customFields, + }) + ); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, customFields)); + }); + it('should get a single configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index d94ff433550e7..407529738b488 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -54,6 +54,31 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql({ ...getConfigurationOutput(true), closure_type: 'close-by-pushing' }); }); + it('should patch a configuration with customFields', async () => { + const customFields = [ + { + key: 'text_field', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_field', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + const configuration = await createConfiguration(supertest); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ ...getConfigurationOutput(true), customFields }); + }); + it('should update mapping when changing connector', async () => { const configuration = await createConfiguration(supertest); await updateConfiguration(supertest, configuration.id, { @@ -87,80 +112,149 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should not patch a configuration with unsupported connector type', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - getConfigurationRequest({ type: '.unsupported' }), - 400 - ); - }); + describe('validation', () => { + it('should not patch a configuration with unsupported connector type', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.unsupported' }), + 400 + ); + }); - it('should not patch a configuration with unsupported connector fields', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), - 400 - ); - }); + it('should not patch a configuration with unsupported connector fields', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); + + it('should handle patch request when there is no configuration', async () => { + const error = await updateConfiguration( + supertest, + 'not-exist', + { closure_type: 'close-by-pushing', version: 'no-version' }, + 404 + ); - it('should handle patch request when there is no configuration', async () => { - const error = await updateConfiguration( - supertest, - 'not-exist', - { closure_type: 'close-by-pushing', version: 'no-version' }, - 404 - ); - - expect(error).to.eql({ - error: 'Not Found', - message: 'Saved object [cases-configure/not-exist] not found', - statusCode: 404, + expect(error).to.eql({ + error: 'Not Found', + message: 'Saved object [cases-configure/not-exist] not found', + statusCode: 404, + }); }); - }); - it('should handle patch request when versions are different', async () => { - const configuration = await createConfiguration(supertest); - const error = await updateConfiguration( - supertest, - configuration.id, - { closure_type: 'close-by-pushing', version: 'no-version' }, - 409 - ); - - expect(error).to.eql({ - error: 'Conflict', - message: - 'This configuration has been updated. Please refresh before saving additional updates.', - statusCode: 409, + it('should handle patch request when versions are different', async () => { + const configuration = await createConfiguration(supertest); + const error = await updateConfiguration( + supertest, + configuration.id, + { closure_type: 'close-by-pushing', version: 'no-version' }, + 409 + ); + + expect(error).to.eql({ + error: 'Conflict', + message: + 'This configuration has been updated. Please refresh before saving additional updates.', + statusCode: 409, + }); }); - }); - it('should not allow to change the owner of the configuration', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - { owner: 'observabilityFixture', version: configuration.version }, - 400 - ); - }); + it('should not allow to change the owner of the configuration', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { owner: 'observabilityFixture', version: configuration.version }, + 400 + ); + }); - it('should not allow excess attributes', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - { notExist: 'not-exist', version: configuration.version }, - 400 - ); + it('should not allow excess attributes', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { notExist: 'not-exist', version: configuration.version }, + 400 + ); + }); + + it('should not allow patching the type of a custom field', async () => { + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + customFields: [ + { + key: 'wrong_type_key', + label: '#1', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + 400 + ); + }); + + it('should not patch a configuration with duplicated custom field keys', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + customFields: [ + { + key: 'triplicated_key', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'triplicated_key', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'triplicated_key', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts index 0b152d5cd9d95..02721521a2e4f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts @@ -6,7 +6,8 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '@kbn/cases-plugin/common/constants'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -52,6 +53,32 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput()); }); + it('should create a configuration with no customFields', async () => { + const { customFields, ...configurationRequest } = getConfigurationRequest(); + const configuration = await createConfiguration(supertest, configurationRequest); + + expect(configuration.customFields).to.eql([]); + }); + + it('should create a configuration with customFields', async () => { + const customFields = { + customFields: [ + { key: 'hello', label: 'text', type: CustomFieldTypes.TEXT, required: false }, + { key: 'goodbye', label: 'toggle', type: CustomFieldTypes.TOGGLE, required: true }, + ], + }; + + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: customFields, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql(getConfigurationOutput(false, customFields)); + }); + it('should keep only the latest configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); @@ -194,126 +221,176 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should not create a configuration when missing connector.id', async () => { - await createConfiguration( - supertest, - { - // @ts-expect-error - connector: { - name: 'Connector', - type: ConnectorTypes.none, - fields: null, + describe('validation', () => { + it('should not create a configuration when missing connector.id', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + name: 'Connector', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.name', async () => { - await createConfiguration( - supertest, - { - // @ts-expect-error - connector: { - id: 'test-id', - type: ConnectorTypes.none, - fields: null, + it('should not create a configuration when missing connector.name', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.type', async () => { - await createConfiguration( - supertest, - { + it('should not create a configuration when missing connector.type', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + name: 'Connector', + fields: null, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when missing connector.fields', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when when missing closure_type', async () => { + await createConfiguration( + supertest, // @ts-expect-error - connector: { - id: 'test-id', - name: 'Connector', - fields: null, + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + fields: null, + }, }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.fields', async () => { - await createConfiguration( - supertest, - { + it('should not create a configuration when missing connector', async () => { + await createConfiguration( + supertest, // @ts-expect-error - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', + { + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when when missing closure_type', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - { - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', - fields: null, + it('should not create a configuration when fields are not null', async () => { + await createConfiguration( + supertest, + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + // @ts-expect-error + fields: {}, + }, + closure_type: 'close-by-user', }, - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - { - closure_type: 'close-by-user', - }, - 400 - ); - }); + it('should not create a configuration with unsupported connector type', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + getConfigurationRequest({ type: '.unsupported' }), + 400 + ); + }); - it('should not create a configuration when fields are not null', async () => { - await createConfiguration( - supertest, - { - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', - // @ts-expect-error - fields: {}, - }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + it('should not create a configuration with unsupported connector fields', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); - it('should not create a configuration with unsupported connector type', async () => { - // @ts-expect-error - await createConfiguration(supertest, getConfigurationRequest({ type: '.unsupported' }), 400); - }); + it('should not create a configuration when customField label is too long', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'hello', + label: '#'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1), + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }), + 400 + ); + }); - it('should not create a configuration with unsupported connector fields', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), - 400 - ); + it('should not create a configuration with duplicated keys', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'duplicated_key', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: '#2', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }), + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 4679a03b4d8af..f5a95fe33d917 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -15,6 +15,8 @@ import { AttachmentType, CreateCaseUserAction, ConnectorTypes, + CustomFieldTypes, + CaseCustomFields, } from '@kbn/cases-plugin/common/types/domain'; import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -30,6 +32,8 @@ import { deleteComment, extractWarningValueFromWarningHeader, getCaseUserActions, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { globalRead, @@ -84,6 +88,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(createCaseUserAction.payload.settings).to.eql(postCaseReq.settings); expect(createCaseUserAction.payload.owner).to.eql(postCaseReq.owner); expect(createCaseUserAction.payload.connector).to.eql(postCaseReq.connector); + expect(createCaseUserAction.payload.assignees).to.eql(postCaseReq.assignees); + expect(createCaseUserAction.payload.severity).to.eql(postCaseReq.severity); + expect(createCaseUserAction.payload.category).to.eql(null); + expect(createCaseUserAction.payload.customFields).to.eql([]); }); it('deletes all user actions when a case is deleted', async () => { @@ -252,12 +260,12 @@ export default ({ getService }: FtrProviderContext): void => { }); const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); - const titleUserAction = userActions[1]; + const descUserAction = userActions[1]; expect(userActions.length).to.eql(2); - expect(titleUserAction.type).to.eql('description'); - expect(titleUserAction.action).to.eql('update'); - expect(titleUserAction.payload).to.eql({ description: newDesc }); + expect(descUserAction.type).to.eql('description'); + expect(descUserAction.action).to.eql('update'); + expect(descUserAction.payload).to.eql({ description: newDesc }); }); it('creates a create comment user action', async () => { @@ -349,6 +357,111 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('creates user actions for custom fields correctly', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'test_custom_field_3', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + + const customFields: CaseCustomFields = [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'test_custom_field_3', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value 3'], + }, + ]; + + const theCase = await createCase(supertest, { + ...postCaseReq, + customFields: [customFields[0], customFields[2]], + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }, + ], + }, + }); + + const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); + expect(userActions.length).to.eql(3); + + const createCaseUserAction = userActions[0] as unknown as CreateCaseUserAction; + const updateCustomFieldCaseUserAction = userActions[1]; + const updateCustomFieldCaseUserAction2 = userActions[2]; + + expect(createCaseUserAction.payload.customFields).to.eql([customFields[0], customFields[2]]); + + expect(updateCustomFieldCaseUserAction.type).to.eql('customFields'); + expect(updateCustomFieldCaseUserAction.action).to.eql('update'); + expect(updateCustomFieldCaseUserAction.payload).to.eql({ + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }); + + expect(updateCustomFieldCaseUserAction2.type).to.eql('customFields'); + expect(updateCustomFieldCaseUserAction2.action).to.eql('update'); + expect(updateCustomFieldCaseUserAction2.payload).to.eql({ + customFields: [ + { + key: 'test_custom_field_3', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }); + }); + describe('rbac', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); diff --git a/x-pack/test/functional/services/cases/common.ts b/x-pack/test/functional/services/cases/common.ts index ef7964935ae41..7a969b310f56f 100644 --- a/x-pack/test/functional/services/cases/common.ts +++ b/x-pack/test/functional/services/cases/common.ts @@ -63,6 +63,7 @@ export function CasesCommonServiceProvider({ getService, getPageObject }: FtrPro const radioGroup = await testSubjects.find(testSubject); const label = await radioGroup.findByCssSelector(`label[for="${value}"]`); await label.click(); + await header.waitUntilLoadingHasFinished(); await this.assertRadioGroupValue(testSubject, value); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index b812bbaeb019b..098297551347d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -13,6 +13,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const cases = getService('cases'); const toasts = getService('toasts'); + const header = getPageObject('header'); describe('Configure', function () { before(async () => { @@ -24,6 +25,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('Closure options', function () { + this.beforeEach(async () => { + await header.waitUntilLoadingHasFinished(); + }); it('defaults the closure option correctly', async () => { await cases.common.assertRadioGroupValue('closure-options-radio-group', 'close-by-user'); }); @@ -33,6 +37,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toast = await toasts.getToastElement(1); expect(await toast.getVisibleText()).to.be('Saved external connection settings'); await toasts.dismissAllToasts(); + await cases.common.assertRadioGroupValue('closure-options-radio-group', 'close-by-pushing'); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts index d61b24d67c223..ff5b776743841 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts @@ -10,7 +10,7 @@ import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../. import { SERVICE_NOW_MAPPING } from '../../../screens/configure_cases'; import { goToEditExternalConnection } from '../../../tasks/all_cases'; -import { cleanKibana, deleteCases, deleteConnectors } from '../../../tasks/common'; +import { cleanKibana, deleteAllCasesItems, deleteConnectors } from '../../../tasks/common'; import { addServiceNowConnector, openAddNewConnectorOption, @@ -35,6 +35,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { error: null, updated_at: null, updated_by: null, + customFields: [], mappings: [ { source: 'title', target: 'short_description', action_type: 'overwrite' }, { source: 'description', target: 'description', action_type: 'overwrite' }, @@ -53,7 +54,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); - deleteCases(); + deleteAllCasesItems(); cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, { statusCode: 200, body: getServiceNowITSMHealthResponse(), diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts index 7db79bd446777..73836e1cd913a 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts @@ -9,7 +9,7 @@ import type { TestCaseWithoutTimeline } from '../../../objects/case'; import { ALL_CASES_CREATE_NEW_CASE_BTN, ALL_CASES_NAME } from '../../../screens/all_cases'; import { goToCreateNewCase } from '../../../tasks/all_cases'; -import { cleanKibana, deleteCases } from '../../../tasks/common'; +import { cleanKibana, deleteAllCasesItems } from '../../../tasks/common'; import { backToCases, @@ -61,7 +61,7 @@ describe('Cases privileges', { tags: ['@ess', '@serverless', '@brokenInServerles beforeEach(() => { login(); - deleteCases(); + deleteAllCasesItems(); }); for (const user of [secAllUser, secReadCasesAllUser, secAllCasesNoDeleteUser]) { diff --git a/x-pack/test/security_solution_cypress/cypress/objects/case.ts b/x-pack/test/security_solution_cypress/cypress/objects/case.ts index 2abdce5fb068c..f30f24fb6ff06 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/case.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/case.ts @@ -74,6 +74,7 @@ export const getCaseResponse = (): Case => ({ status: CaseStatuses.open, severity: CaseSeverity.HIGH, assignees: [], + customFields: [], settings: { syncAlerts: false, }, diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts index 7e45afcc42c49..3c2467c25f6ad 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts @@ -94,7 +94,7 @@ export const resetRulesTableState = () => { export const cleanKibana = () => { resetRulesTableState(); deleteAlertsAndRules(); - deleteCases(); + deleteAllCasesItems(); deleteTimelines(); }; @@ -169,8 +169,8 @@ export const deleteAlertsIndex = () => { }); }; -export const deleteCases = () => { - const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; +export const deleteAllCasesItems = () => { + const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_alerting_cases_\*`; rootRequest({ method: 'POST', url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, @@ -179,8 +179,34 @@ export const deleteCases = () => { bool: { filter: [ { - match: { - type: 'cases', + bool: { + should: [ + { + term: { + type: 'cases', + }, + }, + { + term: { + type: 'cases-configure', + }, + }, + { + term: { + type: 'cases-comments', + }, + }, + { + term: { + type: 'cases-user-action', + }, + }, + { + term: { + type: 'cases-connector-mappings', + }, + }, + ], }, }, ], @@ -191,7 +217,7 @@ export const deleteCases = () => { }; export const deleteConnectors = () => { - const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; + const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_alerting_cases_\*`; rootRequest({ method: 'POST', url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, diff --git a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts index 4163fc70291db..a504b71240dd5 100644 --- a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts +++ b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts @@ -133,6 +133,7 @@ export function SvlCasesApiServiceProvider({ getService }: FtrProviderContext) { status: CaseStatuses.open, updated_by: null, category: null, + customFields: [], }; },