diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index a8868010d2312..557899e322ae7 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -133,6 +133,12 @@ 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_TEMPLATE_KEY_LENGTH = 36 as const; // uuidv4 length +export const MAX_TEMPLATE_NAME_LENGTH = 50 as const; +export const MAX_TEMPLATE_DESCRIPTION_LENGTH = 1000 as const; +export const MAX_TEMPLATES_LENGTH = 10 as const; +export const MAX_TEMPLATE_TAG_LENGTH = 50 as const; +export const MAX_TAGS_PER_TEMPLATE = 10 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/constants/owners.ts b/x-pack/plugins/cases/common/constants/owners.ts index 8ac7164ef75cc..a7628628a7dcc 100644 --- a/x-pack/plugins/cases/common/constants/owners.ts +++ b/x-pack/plugins/cases/common/constants/owners.ts @@ -56,8 +56,8 @@ export const OWNER_INFO: Record = { [GENERAL_CASES_OWNER]: { id: GENERAL_CASES_OWNER, appId: 'management', - label: 'Stack', - iconType: 'casesApp', + label: 'Management', + iconType: 'managementApp', appRoute: '/app/management/insightsAndAlerting', validRuleConsumers: [AlertConsumers.ML, AlertConsumers.STACK_ALERTS, AlertConsumers.EXAMPLE], }, 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 0dff1cac0d95d..7a45f92fa4668 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -58,6 +58,81 @@ export const CaseRequestCustomFieldsRt = limitedArraySchema({ max: MAX_CUSTOM_FIELDS_PER_CASE, }); +export const CaseBaseOptionalFieldsRequestRt = rt.exact( + rt.partial({ + /** + * The description of the case + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 1, + max: MAX_DESCRIPTION_LENGTH, + }), + /** + * The identifying strings for filter a case + */ + tags: limitedArraySchema({ + codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), + min: 0, + max: MAX_TAGS_PER_CASE, + fieldName: 'tags', + }), + /** + * The title of a case + */ + title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), + /** + * The external system that the case can be synced with + */ + connector: CaseConnectorRt, + /** + * The severity of the case + */ + severity: CaseSeverityRt, + /** + * The users assigned to this case + */ + assignees: limitedArraySchema({ + codec: CaseUserProfileRt, + fieldName: 'assignees', + min: 0, + max: MAX_ASSIGNEES_PER_CASE, + }), + /** + * The category of the case. + */ + category: rt.union([ + limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), + rt.null, + ]), + /** + * Custom fields of the case + */ + customFields: CaseRequestCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, + }) +); + +export const CaseRequestFieldsRt = rt.intersection([ + CaseBaseOptionalFieldsRequestRt, + rt.exact( + rt.partial({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + + /** + * The plugin owner of the case + */ + owner: rt.string, + }) + ), +]); + /** * Create case */ @@ -356,71 +431,7 @@ export const CasesBulkGetResponseRt = rt.strict({ * Update cases */ export const CasePatchRequestRt = rt.intersection([ - rt.exact( - rt.partial({ - /** - * The description of the case - */ - description: limitedStringSchema({ - fieldName: 'description', - min: 1, - max: MAX_DESCRIPTION_LENGTH, - }), - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, - /** - * The identifying strings for filter a case - */ - tags: limitedArraySchema({ - codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), - min: 0, - max: MAX_TAGS_PER_CASE, - fieldName: 'tags', - }), - /** - * The title of a case - */ - title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), - /** - * The external system that the case can be synced with - */ - connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, - /** - * The severity of the case - */ - severity: CaseSeverityRt, - /** - * The users assigned to this case - */ - assignees: limitedArraySchema({ - codec: CaseUserProfileRt, - fieldName: 'assignees', - min: 0, - max: MAX_ASSIGNEES_PER_CASE, - }), - /** - * The category of the case. - */ - category: rt.union([ - limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), - rt.null, - ]), - /** - * Custom fields of the case - */ - customFields: CaseRequestCustomFieldsRt, - }) - ), + CaseRequestFieldsRt, /** * The saved object ID and version */ 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 3369cb8473c0c..c16dfbc60eaf7 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 @@ -8,11 +8,24 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { v4 as uuidv4 } from 'uuid'; import { + MAX_ASSIGNEES_PER_CASE, + MAX_CATEGORY_LENGTH, MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, + MAX_TITLE_LENGTH, } from '../../../constants'; +import { CaseSeverity } from '../../domain'; import { ConnectorTypes } from '../../domain/connector/v1'; import { CustomFieldTypes } from '../../domain/custom_field/v1'; import { @@ -23,6 +36,7 @@ import { CustomFieldConfigurationWithoutTypeRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + TemplateConfigurationRt, } from './v1'; describe('configure', () => { @@ -90,6 +104,51 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + tags: [], + caseFields: null, + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -159,6 +218,51 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + caseFields: null, + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: [], + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationPatchRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -407,4 +511,325 @@ describe('configure', () => { ).toContain('Invalid value "foobar" supplied'); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + tags: ['foo', 'bar'], + caseFields: { + title: 'case using sample template', + }, + }; + + it('has expected attributes in request', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('limits key to 36 characters', () => { + const longKey = 'x'.repeat(MAX_TEMPLATE_KEY_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: longKey })) + ).toContain('The length of the key is too long. The maximum length is 36.'); + }); + + it('return error if key is empty', () => { + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: '' })) + ).toContain('The key field cannot be an empty string.'); + }); + + it('returns an error if they key is not in the expected format', () => { + const key = 'Not a proper key'; + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key })) + ).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`); + }); + + it('accepts a uuid as an key', () => { + const key = uuidv4(); + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('accepts a slug as an key', () => { + const key = 'abc_key-1'; + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('does not throw when there is no description or tags', () => { + const newRequest = { + key: 'template_key_1', + name: 'Template 1', + caseFields: null, + }; + + expect(PathReporter.report(TemplateConfigurationRt.decode({ ...newRequest }))).toContain( + 'No errors!' + ); + }); + + it('limits name to 50 characters', () => { + const longName = 'x'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, name: longName })) + ).toContain('The length of the name is too long. The maximum length is 50.'); + }); + + it('limits description to 1000 characters', () => { + const longDesc = 'x'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ ...defaultRequest, description: longDesc }) + ) + ).toContain('The length of the description is too long. The maximum length is 1000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_TEMPLATE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_TEMPLATE + 1).fill('foobar'); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags })) + ).toContain( + `The length of the field template's tags is too long. Array must be of length <= 10.` + ); + }); + + it(`throws an error when the a tag is more than ${MAX_TEMPLATE_TAG_LENGTH} characters`, async () => { + const tag = 'a'.repeat(MAX_TEMPLATE_TAG_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags: [tag] })) + ).toContain(`The length of the template's tag is too long. The maximum length is 50.`); + }); + + it(`throws an error when the a tag is empty string`, async () => { + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, tags: [''] })) + ).toContain(`The template's tag field cannot be an empty string.`); + }); + + describe('caseFields', () => { + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts caseFields as null', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts caseFields as {}', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + + it('accepts caseFields with all fields', () => { + const caseFieldsAll = { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }; + + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: caseFieldsAll, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: caseFieldsAll }, + }); + }); + + 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, assignees }, + }) + ) + ).toContain( + 'The length of the field assignees is too long. Array must be of length <= 10.' + ); + }); + + 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, tags }, + }) + ) + ).toContain('The length of the field tags is too long. Array must be of length <= 200.'); + }); + + it(`throws an error when the tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { + const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, 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: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, 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( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { + ...defaultRequest.caseFields, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.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}.` + ); + }); + }); + }); }); 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 8e986677ae8a9..bd2e1f5c11af0 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -10,12 +10,19 @@ import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, } from '../../../constants'; import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; +import { CaseBaseOptionalFieldsRequestRt } from '../case/v1'; import { CaseCustomFieldTextWithValidationValueRt } from '../custom_field/v1'; export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ @@ -64,6 +71,59 @@ export const CustomFieldsConfigurationRt = limitedArraySchema({ fieldName: 'customFields', }); +export const TemplateConfigurationRt = rt.intersection([ + rt.strict({ + /** + * key of template + */ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_TEMPLATE_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + /** + * name of template + */ + name: limitedStringSchema({ fieldName: 'name', min: 1, max: MAX_TEMPLATE_NAME_LENGTH }), + /** + * case fields + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRequestRt]), + }), + rt.exact( + rt.partial({ + /** + * description of templates + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 0, + max: MAX_TEMPLATE_DESCRIPTION_LENGTH, + }), + /** + * tags of templates + */ + tags: limitedArraySchema({ + codec: limitedStringSchema({ + fieldName: `template's tag`, + min: 1, + max: MAX_TEMPLATE_TAG_LENGTH, + }), + min: 0, + max: MAX_TAGS_PER_TEMPLATE, + fieldName: `template's tags`, + }), + }) + ), +]); + +export const TemplatesConfigurationRt = limitedArraySchema({ + codec: TemplateConfigurationRt, + min: 0, + max: MAX_TEMPLATES_LENGTH, + fieldName: 'templates', +}); + export const ConfigurationRequestRt = rt.intersection([ rt.strict({ /** @@ -82,6 +142,7 @@ export const ConfigurationRequestRt = rt.intersection([ rt.exact( rt.partial({ customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), ]); @@ -106,6 +167,7 @@ export const ConfigurationPatchRequestRt = rt.intersection([ closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), rt.strict({ version: rt.string }), 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 d8da843e46a0c..83d48df363bd2 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -52,15 +52,11 @@ export const CaseSettingsRt = rt.strict({ syncAlerts: rt.boolean, }); -const CaseBasicRt = rt.strict({ +const CaseBaseFields = { /** * The description of the case */ description: rt.string, - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, /** * The identifying strings for filter a case */ @@ -73,14 +69,6 @@ const CaseBasicRt = rt.strict({ * The external system that the case can be synced with */ connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, /** * The severity of the case */ @@ -98,6 +86,28 @@ const CaseBasicRt = rt.strict({ * user-configured custom fields. */ customFields: CaseCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, +}; + +export const CaseBaseOptionalFieldsRt = rt.exact( + rt.partial({ + ...CaseBaseFields, + }) +); + +const CaseBasicRt = rt.strict({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + /** + * The plugin owner of the case + */ + owner: rt.string, + ...CaseBaseFields, }); export const CaseAttributesRt = rt.intersection([ @@ -151,3 +161,4 @@ export type CaseAttributes = rt.TypeOf; export type CaseSettings = rt.TypeOf; export type RelatedCase = rt.TypeOf; export type AttachmentTotals = rt.TypeOf; +export type CaseBaseOptionalFields = rt.TypeOf; 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 400d69700fe12..13637fb4d8c68 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,12 +6,14 @@ */ import { PathReporter } from 'io-ts/lib/PathReporter'; +import { CaseSeverity } from '../case/v1'; import { ConnectorTypes } from '../connector/v1'; import { CustomFieldTypes } from '../custom_field/v1'; import { ConfigurationAttributesRt, ConfigurationRt, CustomFieldConfigurationWithoutTypeRt, + TemplateConfigurationRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, } from './v1'; @@ -45,11 +47,59 @@ describe('configure', () => { required: false, }; + const templateWithAllCaseFields = { + key: 'template_sample_1', + name: 'Sample template 1', + description: 'this is first sample template', + tags: ['foo', 'bar', 'foobar'], + caseFields: { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }; + + const templateWithFewCaseFields = { + key: 'template_sample_2', + name: 'Sample template 2', + tags: [], + caseFields: { + title: 'Case with sample template 2', + tags: ['sample-2'], + }, + }; + + const templateWithNoCaseFields = { + key: 'template_sample_3', + name: 'Sample template 3', + caseFields: null, + }; + describe('ConfigurationAttributesRt', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', customFields: [textCustomField, toggleCustomField], + templates: [], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', created_by: { @@ -110,6 +160,7 @@ describe('configure', () => { connector: serviceNow, closure_type: 'close-by-user', customFields: [], + templates: [templateWithAllCaseFields, templateWithFewCaseFields, templateWithNoCaseFields], created_at: '2020-02-19T23:06:33.798Z', created_by: { full_name: 'Leslie Knope', @@ -299,4 +350,71 @@ describe('configure', () => { }); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = templateWithAllCaseFields; + + it('has expected attributes in request ', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...templateWithAllCaseFields.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts few caseFields', () => { + const query = TemplateConfigurationRt.decode(templateWithFewCaseFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...templateWithFewCaseFields }, + }); + }); + + it('accepts null for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts {} for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + }); }); 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 65882ad40753e..1e4e30c95e381 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -9,6 +9,7 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; +import { CaseBaseOptionalFieldsRt } from '../case/v1'; export const ClosureTypeRt = rt.union([ rt.literal('close-by-user'), @@ -57,6 +58,37 @@ export const CustomFieldConfigurationRt = rt.union([ export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); +export const TemplateConfigurationRt = rt.intersection([ + rt.strict({ + /** + * key of template + */ + key: rt.string, + /** + * name of template + */ + name: rt.string, + /** + * case fields of template + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRt]), + }), + rt.exact( + rt.partial({ + /** + * description of template + */ + description: rt.string, + /** + * tags of template + */ + tags: rt.array(rt.string), + }) + ), +]); + +export const TemplatesConfigurationRt = rt.array(TemplateConfigurationRt); + export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** * The external connector @@ -70,6 +102,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * The custom fields configured for the case */ customFields: CustomFieldsConfigurationRt, + /** + * Templates configured for the case + */ + templates: TemplatesConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -109,6 +145,8 @@ export const ConfigurationsRt = rt.array(ConfigurationRt); export type CustomFieldsConfiguration = rt.TypeOf; export type CustomFieldConfiguration = rt.TypeOf; +export type TemplatesConfiguration = rt.TypeOf; +export type TemplateConfiguration = 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/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3854c14c79de8..6d75b30dd119d 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -119,10 +119,18 @@ export interface ResolvedCase { export type CasesConfigurationUI = Pick< SnakeToCamelCase, - 'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version' | 'owner' + | 'closureType' + | 'connector' + | 'mappings' + | 'customFields' + | 'templates' + | 'id' + | 'version' + | 'owner' >; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; +export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number]; export type SortOrder = 'asc' | 'desc'; diff --git a/x-pack/plugins/cases/public/components/create/assignees.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx similarity index 56% rename from x-pack/plugins/cases/public/components/create/assignees.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx index 83b7802ce4a12..f0b73cb8bf990 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx @@ -15,7 +15,6 @@ import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_l import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { userProfiles } from '../../containers/user_profiles/api.mock'; import { Assignees } from './assignees'; -import type { FormProps } from './schema'; import { act, waitFor, screen } from '@testing-library/react'; import * as api from '../../containers/user_profiles/api'; import type { UserProfile } from '@kbn/user-profile-components'; @@ -29,7 +28,7 @@ describe('Assignees', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC> = ({ children }) => { - const { form } = useForm(); + const { form } = useForm(); globalForm = form; return
{children}
; @@ -41,113 +40,99 @@ describe('Assignees', () => { }); it('renders', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('does not render the assign yourself link when the current user profile is undefined', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(undefined as unknown as UserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(screen.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('selects the current user correctly', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); - }); + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); it('disables the assign yourself button if the current user is already selected', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); - expect(result.getByTestId('create-case-assign-yourself-link')).toBeDisabled(); + expect(await screen.findByTestId('create-case-assign-yourself-link')).toBeDisabled(); }); it('assignees users correctly', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - await act(async () => { - await userEvent.type(result.getByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - }); + await userEvent.type(await screen.findByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - await waitFor(() => { - expect( - result.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') - ).toBeInTheDocument(); - }); + expect( + await screen.findByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') + ).toBeInTheDocument(); - await waitFor(async () => { - expect(result.getByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - }); + expect(await screen.findByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - act(() => { - userEvent.click(result.getByText(`${currentUserProfile.user.full_name}`)); - }); + userEvent.click(await screen.findByText(`${currentUserProfile.user.full_name}`)); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); @@ -186,25 +171,62 @@ describe('Assignees', () => { ); await waitFor(() => { - expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); + userEvent.click(await screen.findByTestId('comboBoxSearchInput')); + + expect(await screen.findByText('Turtle')).toBeInTheDocument(); + expect(await screen.findByText('turtle')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); + + // ensure that the similar user is still available for selection + expect(await screen.findByText('turtle')).toBeInTheDocument(); + }); + + it('fetches the unknown user profiles using bulk_get', async () => { + // the profile is not returned by the suggest API + const userProfile = { + uid: 'u_qau3P4T1H-_f1dNHyEOWJzVkGQhLH1gnNMVvYxqmZcs_0', + enabled: true, + data: {}, + user: { + username: 'uncertain_crawdad', + email: 'uncertain_crawdad@profiles.elastic.co', + full_name: 'Uncertain Crawdad', + }, + }; + + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + spyOnBulkGetUserProfiles.mockResolvedValue([userProfile]); + + appMockRender.render( + + + + ); + + expect(screen.queryByText(userProfile.user.full_name)).not.toBeInTheDocument(); + act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); + globalForm.setFieldValue('assignees', [{ uid: userProfile.uid }]); }); await waitFor(() => { - expect(screen.getByText('Turtle')).toBeInTheDocument(); - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(globalForm.getFormData()).toEqual({ + assignees: [{ uid: userProfile.uid }], + }); }); - act(() => { - userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); - }); - - // ensure that the similar user is still available for selection await waitFor(() => { - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(spyOnBulkGetUserProfiles).toBeCalledTimes(1); + expect(spyOnBulkGetUserProfiles).toHaveBeenCalledWith({ + security: expect.anything(), + uids: [userProfile.uid], + }); }); + + expect(await screen.findByText(userProfile.user.full_name)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx similarity index 61% rename from x-pack/plugins/cases/public/components/create/assignees.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx index 1e8464dc1a2ed..6e56e7d154a2a 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash'; +import { isEmpty, differenceWith } from 'lodash'; import React, { memo, useCallback, useState } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { @@ -23,31 +23,35 @@ import type { FieldConfig, FieldHook } from '@kbn/es-ui-shared-plugin/static/for import { UseField, getFieldValidityAndErrorMessage, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { CaseAssignees } from '../../../common/types/domain'; import { MAX_ASSIGNEES_PER_CASE } from '../../../common/constants'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; -import { OptionalFieldLabel } from './optional_field_label'; -import * as i18n from './translations'; +import { OptionalFieldLabel } from '../optional_field_label'; +import * as i18n from '../create/translations'; import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../../utils/permissions'; import { useIsUserTyping } from '../../common/use_is_user_typing'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; + +const FIELD_ID = 'assignees'; interface Props { isLoading: boolean; } +type UserProfileComboBoxOption = EuiComboBoxOptionOption & UserProfileWithAvatar; + interface FieldProps { - field: FieldHook; - options: EuiComboBoxOptionOption[]; + field: FieldHook; + options: UserProfileComboBoxOption[]; isLoading: boolean; isDisabled: boolean; currentUserProfile?: UserProfile; - selectedOptions: EuiComboBoxOptionOption[]; - setSelectedOptions: React.Dispatch>; onSearchComboChange: (value: string) => void; } @@ -73,28 +77,32 @@ const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({ data: userProfile.data, }); -const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value }); +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ + uid: option.value ?? '', +}); const AssigneesFieldComponent: React.FC = React.memo( - ({ - field, - isLoading, - isDisabled, - options, - currentUserProfile, - selectedOptions, - setSelectedOptions, - onSearchComboChange, - }) => { - const { setValue } = field; + ({ field, isLoading, isDisabled, options, currentUserProfile, onSearchComboChange }) => { + const { setValue, value: selectedAssignees } = field; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const selectedOptions: UserProfileComboBoxOption[] = selectedAssignees + .map(({ uid }) => { + const selectedUserProfile = options.find((userProfile) => userProfile.key === uid); + + if (selectedUserProfile) { + return selectedUserProfile; + } + + return null; + }) + .filter((value): value is UserProfileComboBoxOption => value != null); + const onComboChange = useCallback( - (currentOptions: EuiComboBoxOptionOption[]) => { - setSelectedOptions(currentOptions); + (currentOptions: Array>) => { setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option))); }, - [setSelectedOptions, setValue] + [setValue] ); const onSelfAssign = useCallback(() => { @@ -102,62 +110,51 @@ const AssigneesFieldComponent: React.FC = React.memo( return; } - setSelectedOptions((prev) => [ - ...(prev ?? []), - userProfileToComboBoxOption(currentUserProfile), - ]); - - setValue([ - ...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []), - { uid: currentUserProfile.uid }, - ]); - }, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]); + setValue([...selectedAssignees, { uid: currentUserProfile.uid }]); + }, [currentUserProfile, selectedAssignees, setValue]); - const renderOption = useCallback( - (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { - const { user, data } = option as EuiComboBoxOptionOption & UserProfileWithAvatar; + const renderOption = useCallback((option, searchValue: string, contentClassName: string) => { + const { user, data } = option as UserProfileComboBoxOption; - const displayName = getUserDisplayName(user); + const displayName = getUserDisplayName(user); - return ( + return ( + + + + - - + + + {displayName} + - - - - {displayName} - + {user.email && user.email !== displayName ? ( + + + + {user.email} + + - {user.email && user.email !== displayName ? ( - - - - {user.email} - - - - ) : null} - + ) : null} - ); - }, - [] - ); + + ); + }, []); const isCurrentUserSelected = Boolean( - selectedOptions?.find((option) => option.value === currentUserProfile?.uid) + selectedAssignees?.find((assignee) => assignee.uid === currentUserProfile?.uid) ); return ( @@ -179,6 +176,7 @@ const AssigneesFieldComponent: React.FC = React.memo( } isInvalid={isInvalid} error={errorMessage} + data-test-subj="caseAssignees" > = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); + const [{ assignees }] = useFormData<{ assignees?: CaseAssignees }>({ watch: [FIELD_ID] }); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); - const [selectedOptions, setSelectedOptions] = useState(); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const hasOwners = owners.length > 0; @@ -212,7 +210,7 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { useGetCurrentUserProfile(); const { - data: userProfiles, + data: userProfiles = [], isLoading: isLoadingSuggest, isFetching: isFetchingSuggest, } = useSuggestUserProfiles({ @@ -221,10 +219,22 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { onDebounce, }); + const assigneesWithoutProfiles = differenceWith( + assignees ?? [], + userProfiles ?? [], + (assignee, userProfile) => assignee.uid === userProfile.uid + ); + + const { data: bulkUserProfiles = new Map(), isFetching: isLoadingBulkGetUserProfiles } = + useBulkGetUserProfiles({ uids: assigneesWithoutProfiles.map((assignee) => assignee.uid) }); + + const bulkUserProfilesAsArray = Array.from(bulkUserProfiles).map(([_, profile]) => profile); + const options = - bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles)?.map((userProfile) => - userProfileToComboBoxOption(userProfile) - ) ?? []; + bringCurrentUserToFrontAndSort(currentUserProfile, [ + ...userProfiles, + ...bulkUserProfilesAsArray, + ])?.map((userProfile) => userProfileToComboBoxOption(userProfile)) ?? []; const onSearchComboChange = (value: string) => { if (!isEmpty(value)) { @@ -237,22 +247,21 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { const isLoading = isLoadingForm || isLoadingCurrentUserProfile || + isLoadingBulkGetUserProfiles || isLoadingSuggest || isFetchingSuggest || isUserTyping; - const isDisabled = isLoadingForm || isLoadingCurrentUserProfile; + const isDisabled = isLoadingForm || isLoadingCurrentUserProfile || isLoadingBulkGetUserProfiles; return ( { const onSubmit = jest.fn(); const FormComponent: FC> = ({ children }) => { - const { form } = useForm({ onSubmit }); + const { form } = useForm({ onSubmit }); return (
diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx similarity index 93% rename from x-pack/plugins/cases/public/components/create/category.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/category.tsx index 879a8dfb9bbea..d5df6118094e6 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { useGetCategories } from '../../containers/use_get_categories'; import { CategoryFormField } from '../category/category_form_field'; -import { OptionalFieldLabel } from './optional_field_label'; +import { OptionalFieldLabel } from '../optional_field_label'; interface Props { isLoading: boolean; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx new file mode 100644 index 0000000000000..0f80652c9ac03 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx @@ -0,0 +1,164 @@ +/* + * 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/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { connectorsMock } from '../../containers/mock'; +import { Connector } from './connector'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; +import { noConnectorsCasePermission, createAppMockRenderer } from '../../common/mock'; + +import { FormTestComponent } from '../../common/test_utils'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; + +const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes, +}; + +const useGetSeverityResponse = { + isLoading: false, + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, +}; + +const defaultProps = { + connectors: connectorsMock, + isLoading: false, + isLoadingConnectors: false, +}; + +describe('Connector', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields')).not.toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + expect(await screen.findByTestId('dropdown-connectors')).toBeDisabled(); + }); + + it('renders default connector correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByText('Jira')).toBeInTheDocument(); + + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('shows all connectors in dropdown', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[0].id}`) + ).toBeInTheDocument(); + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[1].id}`) + ).toBeInTheDocument(); + }); + + it('changes connector correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + userEvent.click(await screen.findByTestId('dropdown-connector-resilient-2')); + + expect(await screen.findByTestId('connector-fields-resilient')).toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + appMockRender.render( + + + + ); + expect( + await screen.findByTestId('create-case-connector-permissions-error-msg') + ).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have access to case connector', async () => { + appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); + + appMockRender.render( + + + + ); + expect(screen.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx similarity index 62% rename from x-pack/plugins/cases/public/components/create/connector.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/connector.tsx index 39e04f7bc0be3..5ed37c262ec17 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiFormRow } from '@elastic/eui'; import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; @@ -14,7 +14,6 @@ import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { schema } from './schema'; -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,21 +28,10 @@ interface Props { const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; - - const { - data: { connector: configurationConnector }, - } = useGetCaseConfiguration(); - const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; - const defaultConnectorId = useMemo(() => { - return connectors.some((c) => c.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [configurationConnector.id, connectors]); - const connectorIdConfig = getConnectorsFormValidators({ config: schema.connectorId as FieldConfig, connectors, @@ -58,26 +46,27 @@ const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingC } return ( - - - - - - - - + + + + + + + + + + ); }; diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx similarity index 67% rename from x-pack/plugins/cases/public/components/create/custom_fields.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx index 8ab517c497cde..95f7ef1aaa09b 100644 --- a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx @@ -15,40 +15,32 @@ import { FormTestComponent } from '../../common/test_utils'; import { customFieldsConfigurationMock } from '../../containers/mock'; import { CustomFields } from './custom_fields'; import * as i18n from './translations'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; - -jest.mock('../../containers/configure/use_get_all_case_configurations'); - -const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; describe('CustomFields', () => { let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); + const defaultProps = { + configurationCustomFields: customFieldsConfigurationMock, + isLoading: false, + setCustomFieldsOptional: false, + isEditMode: false, + }; + beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: customFieldsConfigurationMock, - }, - ], - })); }); it('renders correctly', async () => { appMockRender.render( - + ); expect(await screen.findByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); for (const item of customFieldsConfigurationMock) { expect( @@ -58,19 +50,13 @@ describe('CustomFields', () => { }); it('should not show the custom fields if the configuration is empty', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [], - }, - ], - })); - appMockRender.render( - + ); @@ -78,26 +64,51 @@ describe('CustomFields', () => { expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); }); + it('should render as optional fields for text custom fields', async () => { + appMockRender.render( + + + + ); + + expect(screen.getAllByTestId('form-optional-field-label')).toHaveLength(2); + }); + + it('should not set default value when in edit mode', async () => { + appMockRender.render( + + + + ); + + expect( + screen.queryByText(`${customFieldsConfigurationMock[0].defaultValue}`) + ).not.toBeInTheDocument(); + }); + it('should sort the custom fields correctly', async () => { const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse(); - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: reversedCustomFieldsConfiguration, - }, - ], - })); - appMockRender.render( - + ); - const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields'); + const customFieldsWrapper = await screen.findByTestId('caseCustomFields'); const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); @@ -110,11 +121,9 @@ describe('CustomFields', () => { }); it('should update the custom fields', async () => { - appMockRender = createAppMockRenderer(); - appMockRender.render( - + ); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx new file mode 100644 index 0000000000000..f2b39b352a964 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -0,0 +1,78 @@ +/* + * 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, EuiFormRow } 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; + configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; + isEditMode?: boolean; +} + +const CustomFieldsComponent: React.FC = ({ + isLoading, + setCustomFieldsOptional = false, + configurationCustomFields, + isEditMode, +}) => { + const sortedCustomFields = useMemo( + () => sortCustomFieldsByLabel(configurationCustomFields), + [configurationCustomFields] + ); + + const customFieldsComponents = sortedCustomFields.map( + (customField: CasesConfigurationUI['customFields'][number]) => { + const customFieldFactory = customFieldsBuilderMap[customField.type]; + const customFieldType = customFieldFactory().build(); + + const CreateComponent = customFieldType.Create; + + return ( + + ); + } + ); + + if (!configurationCustomFields.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/description.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/description.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx index 5acd5a3b4f5c8..8d841da78b362 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx @@ -10,7 +10,7 @@ import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Description } from './description'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { MAX_DESCRIPTION_LENGTH } from '../../../common/constants'; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/description.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.tsx index 5c512e701c123..881ea13c19c3d 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/description.tsx @@ -12,7 +12,7 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; - draftStorageKey: string; + draftStorageKey?: string; } export const fieldName = 'description'; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx new file mode 100644 index 0000000000000..e095a8a915b76 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx @@ -0,0 +1,330 @@ +/* + * 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 { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; + +import { CaseFormFields } from '.'; +import userEvent from '@testing-library/user-event'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../../containers/user_profiles/api'); + +describe('CaseFormFields', () => { + let appMock: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { tags: [] }; + const defaultProps = { + isLoading: false, + configurationCustomFields: [], + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + }); + + it('renders case fields correctly', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('does not render customFields when empty', () => { + appMock.render( + + + + ); + + expect(screen.queryByTestId('caseCustomFields')).not.toBeInTheDocument(); + }); + + it('renders customFields when not empty', async () => { + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('does not render assignees when no platinum license', () => { + appMock.render( + + + + ); + + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); + }); + + it('renders assignees when platinum license', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); + + it('calls onSubmit with case fields', async () => { + appMock.render( + + + + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + }, + true + ); + }); + }); + + it('calls onSubmit with existing case fields', async () => { + appMock.render( + + + + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: ['case-tag-1', 'case-tag-2'], + description: 'This is a case description', + title: 'Case with Template 1', + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + }, + true + ); + }); + }); + + it('calls onSubmit with existing custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + + + + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + customFields: { + test_key_1: 'Test custom filed value', + test_key_2: true, + test_key_4: false, + }, + }, + true + ); + }); + }); + + it('calls onSubmit with assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + userEvent.click(await within(assigneesComboBox).findByTestId('comboBoxToggleListButton')); + + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + assignees: [{ uid: userProfiles[0].uid }], + }, + true + ); + }); + }); + + it('calls onSubmit with existing assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + assignees: [{ uid: userProfiles[1].uid }], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx new file mode 100644 index 0000000000000..5232529e59cef --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.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 React, { memo } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; +import { Title } from './title'; +import { Tags } from './tags'; +import { Category } from './category'; +import { Severity } from './severity'; +import { Description } from './description'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { Assignees } from './assignees'; +import { CustomFields } from './custom_fields'; +import type { CasesConfigurationUI } from '../../containers/types'; + +interface Props { + isLoading: boolean; + configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; + isEditMode?: boolean; + draftStorageKey?: string; +} + +const CaseFormFieldsComponent: React.FC = ({ + isLoading, + configurationCustomFields, + setCustomFieldsOptional = false, + isEditMode, + draftStorageKey, +}) => { + const { caseAssignmentAuthorized } = useCasesFeatures(); + + return ( + + + {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} + <Tags isLoading={isLoading} /> + <Category isLoading={isLoading} /> + <Severity isLoading={isLoading} /> + <Description isLoading={isLoading} draftStorageKey={draftStorageKey} /> + <CustomFields + isLoading={isLoading} + setCustomFieldsOptional={setCustomFieldsOptional} + configurationCustomFields={configurationCustomFields} + isEditMode={isEditMode} + /> + </EuiFlexGroup> + ); +}; + +CaseFormFieldsComponent.displayName = 'CaseFormFields'; + +export const CaseFormFields = memo(CaseFormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx new file mode 100644 index 0000000000000..9c501dafff883 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { CasePostRequest } from '../../../common'; +import { + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import { SEVERITY_TITLE } from '../severity/translations'; +import type { ConnectorTypeFields } from '../../../common/types/domain'; +import * as i18n from './translations'; +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import { OptionalFieldLabel } from '../optional_field_label'; + +const { maxLengthField } = fieldValidators; + +export type CaseFormFieldsSchemaProps = Omit< + CasePostRequest, + 'connector' | 'settings' | 'owner' | 'customFields' +> & { + connectorId: string; + fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; + customFields: Record<string, string | boolean>; +}; + +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { + title: { + label: i18n.NAME, + validations: [ + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + }), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + validations: [ + { + validator: maxLengthField({ + length: MAX_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + }), + }, + ], + }, + tags: { + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + limit: MAX_LENGTH_PER_TAG, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + limit: MAX_TAGS_PER_CASE, + }), + }, + ], + }, + severity: { + label: SEVERITY_TITLE, + }, + assignees: { labelAppend: OptionalFieldLabel }, + category: { + labelAppend: OptionalFieldLabel, + }, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + defaultValue: true, + }, + customFields: {}, + connectorId: { + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: { + defaultValue: null, + }, +}; diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx new file mode 100644 index 0000000000000..959dcba6d4e7e --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { SyncAlertsToggle } from './sync_alerts_toggle'; +import { schema } from '../create/schema'; +import { FormTestComponent } from '../../common/test_utils'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +describe('SyncAlertsToggle', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultFormProps = { + onSubmit, + formDefaultValue: { syncAlerts: true }, + schema: { + syncAlerts: schema.syncAlerts, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('it renders', async () => { + appMockRender.render( + <FormTestComponent> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'true'); + expect(await screen.findByText('On')).toBeInTheDocument(); + }); + + it('it toggles the switch', async () => { + appMockRender.render( + <FormTestComponent> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + const synAlerts = await screen.findByTestId('caseSyncAlerts'); + + userEvent.click(within(synAlerts).getByRole('switch')); + + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'false'); + expect(await screen.findByText('Off')).toBeInTheDocument(); + }); + + it('calls onSubmit with correct data', async () => { + appMockRender.render( + <FormTestComponent {...defaultFormProps}> + <SyncAlertsToggle isLoading={false} /> + </FormTestComponent> + ); + + const synAlerts = await screen.findByTestId('caseSyncAlerts'); + + userEvent.click(within(synAlerts).getByRole('switch')); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + syncAlerts: false, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx similarity index 76% rename from x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx index 1a189de3e17ec..de9395946ffa7 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx @@ -6,11 +6,9 @@ */ import React, { memo } from 'react'; -import { getUseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import * as i18n from './translations'; - -const CommonUseField = getUseField({ component: Field }); +import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; @@ -18,9 +16,12 @@ interface Props { const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading }) => { const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( - <CommonUseField + <UseField path="syncAlerts" + component={ToggleField} + config={{ defaultValue: true }} componentProps={{ idAria: 'caseSyncAlerts', 'data-test-subj': 'caseSyncAlerts', diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx similarity index 95% rename from x-pack/plugins/cases/public/components/create/tags.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx index ed78d78928f0e..78f0cfce49f5f 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx @@ -13,12 +13,12 @@ import userEvent from '@testing-library/user-event'; 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 { Tags } from './tags'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { MAX_LENGTH_PER_TAG } from '../../../common/constants'; +import type { CaseFormFieldsSchemaProps } from './schema'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/use_get_tags'); @@ -30,7 +30,7 @@ describe('Tags', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { tags: [] }, schema: { tags: schema.tags, diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx similarity index 80% rename from x-pack/plugins/cases/public/components/create/tags.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.tsx index f3d4319dfea37..422e89a91afd8 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx @@ -7,13 +7,10 @@ import React, { memo, useMemo } from 'react'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { useGetTags } from '../../containers/use_get_tags'; -import * as i18n from './translations'; - -const CommonUseField = getUseField({ component: Field }); - +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; } @@ -29,8 +26,9 @@ const TagsComponent: React.FC<Props> = ({ isLoading }) => { ); return ( - <CommonUseField + <UseField path="tags" + component={ComboBoxField} componentProps={{ idAria: 'caseTags', 'data-test-subj': 'caseTags', diff --git a/x-pack/plugins/cases/public/components/create/title.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx similarity index 92% rename from x-pack/plugins/cases/public/components/create/title.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx index 382ee67cc494c..73e6c19f90118 100644 --- a/x-pack/plugins/cases/public/components/create/title.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx @@ -13,14 +13,14 @@ import { act } from '@testing-library/react'; 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 { Title } from './title'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; +import type { CaseFormFieldsSchemaProps } from './schema'; describe('Title', () => { let globalForm: FormHook; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { title: 'My title' }, schema: { title: schema.title, diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.tsx similarity index 87% rename from x-pack/plugins/cases/public/components/create/title.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.tsx index 35de4c7a41ccb..8727a3cc01964 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/title.tsx @@ -12,16 +12,17 @@ const CommonUseField = getUseField({ component: Field }); interface Props { isLoading: boolean; + autoFocus?: boolean; } -const TitleComponent: React.FC<Props> = ({ isLoading }) => ( +const TitleComponent: React.FC<Props> = ({ isLoading, autoFocus = false }) => ( <CommonUseField path="title" componentProps={{ idAria: 'caseTitle', 'data-test-subj': 'caseTitle', euiFieldProps: { - autoFocus: true, + autoFocus, fullWidth: true, disabled: isLoading, }, diff --git a/x-pack/plugins/cases/public/components/case_form_fields/translations.ts b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts new file mode 100644 index 0000000000000..b8359958025b3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 ADDITIONAL_FIELDS = i18n.translate('xpack.cases.additionalFields', { + defaultMessage: 'Additional fields', +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts new file mode 100644 index 0000000000000..a8a948d88a158 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import * as i18n from './translations'; + +describe('utils', () => { + describe('validateEmptyTags', () => { + const message = i18n.TAGS_EMPTY_ERROR; + it('returns no error for non empty tags', () => { + expect(validateEmptyTags({ value: ['coke', 'pepsi'], message })).toBeUndefined(); + }); + + it('returns no error for non empty tag', () => { + expect(validateEmptyTags({ value: 'coke', message })).toBeUndefined(); + }); + + it('returns error for empty tags', () => { + expect(validateEmptyTags({ value: [' ', 'pepsi'], message })).toEqual({ message }); + }); + + it('returns error for empty tag', () => { + expect(validateEmptyTags({ value: ' ', message })).toEqual({ message }); + }); + }); + + describe('validateMaxLength', () => { + const limit = 5; + const message = i18n.MAX_LENGTH_ERROR('tag', limit); + + it('returns error for tags exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi!'], + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns error for tag exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello!', + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns no error for tags not exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi'], + message, + limit, + }) + ).toBeUndefined(); + }); + + it('returns no error for tag not exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello', + message, + limit, + }) + ).toBeUndefined(); + }); + }); + + describe('validateMaxTagsLength', () => { + const limit = 2; + const message = i18n.MAX_TAGS_ERROR(limit); + + it('returns error when tags exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi', 'fanta'], message, limit })).toEqual({ + message, + }); + }); + + it('returns no error when tags do not exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi'], message, limit })).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts new file mode 100644 index 0000000000000..1fde95ff54089 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const isInvalidTag = (value: string) => value.trim() === ''; + +const isTagCharactersInLimit = (value: string, limit: number) => value.trim().length > limit; + +export const validateEmptyTags = ({ + value, + message, +}: { + value: string | string[]; + message: string; +}) => { + if ( + (!Array.isArray(value) && isInvalidTag(value)) || + (Array.isArray(value) && value.length > 0 && value.find((item) => isInvalidTag(item))) + ) { + return { + message, + }; + } +}; + +export const validateMaxLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if ( + (!Array.isArray(value) && isTagCharactersInLimit(value, limit)) || + (Array.isArray(value) && + value.length > 0 && + value.some((item) => isTagCharactersInLimit(item, limit))) + ) { + return { + message, + }; + } +}; + +export const validateMaxTagsLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if (Array.isArray(value) && value.length > limit) { + return { + message, + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index 52a40ca065214..d8c98e42f2e38 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -18,17 +18,17 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import * as i18n from '../../tags/translations'; import { useGetTags } from '../../../containers/use_get_tags'; import { Tags } from '../../tags/tags'; import { useCasesContext } from '../../cases_context/use_cases_context'; -import { schemaTags } from '../../create/schema'; +import { schema as createCaseSchema } from '../../create/schema'; -export const schema: FormSchema = { - tags: schemaTags, +export const schema = { + tags: createCaseSchema.tags as FieldConfig<string[]>, }; export interface EditTagsProps { diff --git a/x-pack/plugins/cases/public/components/category/category_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_component.test.tsx index 6eb95600cd58c..e5be97ec20585 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.test.tsx @@ -54,9 +54,9 @@ describe('Category ', () => { render(<CategoryComponent {...defaultProps} />); userEvent.type(screen.getByRole('combobox'), 'new{enter}'); - - expect(onChange).toBeCalledWith('new'); - expect(screen.getByRole('combobox')).toHaveValue('new'); + await waitFor(() => { + expect(onChange).toBeCalledWith('new'); + }); }); it('renders current option list', async () => { @@ -74,7 +74,6 @@ describe('Category ', () => { userEvent.click(screen.getByText('foo')); expect(onChange).toHaveBeenCalledWith('foo'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('foo'); }); it('should call onChange when adding new category', async () => { @@ -84,7 +83,6 @@ describe('Category ', () => { await waitFor(() => { expect(onChange).toHaveBeenCalledWith('hi'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('hi'); }); }); @@ -100,7 +98,7 @@ describe('Category ', () => { userEvent.type(screen.getByRole('combobox'), ' there{enter}'); await waitFor(() => { - expect(onChange).toHaveBeenCalledWith('hi there'); + expect(onChange).toHaveBeenCalledWith('there'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/category/category_component.tsx b/x-pack/plugins/cases/public/components/category/category_component.tsx index ee6f84a244062..f57ba7b36a5ad 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import { ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX } from './translations'; +import type { CaseUI } from '../../../common/ui'; + +export type CategoryField = CaseUI['category'] | undefined; export interface CategoryComponentProps { isLoading: boolean; @@ -26,15 +29,11 @@ export const CategoryComponent: React.FC<CategoryComponentProps> = React.memo( })); }, [availableCategories]); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - category != null ? [{ label: category }] : [] - ); + const selectedOptions = category != null ? [{ label: category }] : []; const onComboChange = useCallback( (currentOptions: Array<EuiComboBoxOptionOption<string>>) => { const value = currentOptions[0]?.label; - - setSelectedOptions(currentOptions); onChange(value); }, [onChange] diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index 060e0928b8986..f8bb2221ce7d8 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -15,7 +15,7 @@ import { import { isEmpty } from 'lodash'; import React, { memo } from 'react'; import { MAX_CATEGORY_LENGTH } from '../../../common/constants'; -import type { CaseUI } from '../../../common/ui'; +import type { CategoryField } from './category_component'; import { CategoryComponent } from './category_component'; import { CATEGORY, EMPTY_CATEGORY_VALIDATION_MSG, MAX_LENGTH_ERROR } from './translations'; @@ -25,8 +25,6 @@ interface Props { formRowProps?: Partial<EuiFormRowProps>; } -type CategoryField = CaseUI['category'] | undefined; - const getCategoryConfig = (): FieldConfig<CategoryField> => ({ defaultValue: null, validations: [ @@ -65,7 +63,7 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ formRowProps, }) => { return ( - <UseField<CategoryField> path={'category'} config={getCategoryConfig()}> + <UseField<CategoryField> path="category" config={getCategoryConfig()}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -79,7 +77,7 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ label={CATEGORY} error={errorMessage} isInvalid={isInvalid} - data-test-subj="case-create-form-category" + data-test-subj="caseCategory" fullWidth > <CategoryComponent 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 e0161e437e70d..bf1ace60ced91 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 @@ -19,7 +19,7 @@ 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)))'; const mockConfigurationData = { - closureType: 'close-by-user', + closureType: 'close-by-user' as const, connector: { fields: null, id: 'none', @@ -27,6 +27,7 @@ const mockConfigurationData = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 73e6c60a90054..71df212399bc2 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { Suspense, useMemo } from 'react'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, @@ -14,6 +14,7 @@ import { EuiIconTip, EuiSuperSelect, useEuiTheme, + EuiLoadingSpinner, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -31,6 +32,15 @@ export interface Props { appendAddConnectorButton?: boolean; } +const suspendedComponentWithProps = (ComponentToSuspend: React.ComponentType) => { + // eslint-disable-next-line react/display-name + return (props: Record<string, unknown>) => ( + <Suspense fallback={<EuiLoadingSpinner size={'m'} />}> + <ComponentToSuspend {...props} /> + </Suspense> + ); +}; + const ICON_SIZE = 'm'; const noConnectorOption = { @@ -90,6 +100,8 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ const connectorsAsOptions = useMemo(() => { const connectorsFormatted = connectors.reduce( (acc, connector) => { + const iconClass = getConnectorIcon(triggersActionsUi, connector.actionTypeId); + return [ ...acc, { @@ -102,7 +114,11 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ margin-right: ${euiTheme.size.m}; margin-bottom: 0 !important; `} - type={getConnectorIcon(triggersActionsUi, connector.actionTypeId)} + type={ + typeof iconClass === 'string' + ? iconClass + : suspendedComponentWithProps(iconClass) + } size={ICON_SIZE} /> </EuiFlexItem> diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx new file mode 100644 index 0000000000000..ce46d368a5d2e --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx @@ -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 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 = { + title: 'My custom field', + message: 'This is a sample message', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..a994c8720cc17 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.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 { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from '../custom_fields/translations'; + +interface ConfirmDeleteCaseModalProps { + title: string; + message: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC<ConfirmDeleteCaseModalProps> = ({ + title, + message, + onCancel, + onConfirm, +}) => { + return ( + <EuiConfirmModal + buttonColor="danger" + cancelButtonText={i18n.CANCEL} + data-test-subj="confirm-delete-modal" + defaultFocusedButton="confirm" + onCancel={onCancel} + onConfirm={onConfirm} + title={title} + confirmButtonText={i18n.DELETE} + > + {message} + </EuiConfirmModal> + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx new file mode 100644 index 0000000000000..555f5e6f553b8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -0,0 +1,798 @@ +/* + * 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 { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; +import { + MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, +} from '../../../common/constants'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; +import * as i18n from './translations'; +import type { FlyOutBodyProps } from './flyout'; +import { CommonFlyout } from './flyout'; +import type { TemplateFormProps } from '../templates/types'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +jest.mock('../connectors/servicenow/use_get_choices'); +jest.mock('../../containers/user_profiles/api'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('CommonFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + renderHeader: () => <div>{`Flyout header`}</div>, + }; + + const children = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> + ); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders flyout correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-header')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-cancel')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-save')).toBeInTheDocument(); + }); + + it('renders flyout header correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + expect(await screen.findByText('Flyout header')); + }); + + it('renders loading state correctly', async () => { + appMockRender.render( + <CommonFlyout {...{ ...props, isLoading: true }}>{children}</CommonFlyout> + ); + + expect(await screen.findAllByRole('progressbar')).toHaveLength(2); + }); + + it('renders disable state correctly', async () => { + appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }}>{children}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout-cancel')).toBeDisabled(); + expect(await screen.findByTestId('common-flyout-save')).toBeDisabled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('common-flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('does not call onSaveField when not valid data', async () => { + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + expect(props.onSaveField).not.toBeCalled(); + }); + + describe('CustomFieldsFlyout', () => { + const renderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> + ); + + it('should render custom field form in flyout', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + expect(await screen.findByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(await screen.findByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-required-wrapper')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-default-value')).toBeInTheDocument(); + }); + + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('shows error if field label is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + userEvent.type(await screen.findByTestId('custom-field-label-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) + ) + ).toBeInTheDocument(); + }); + + describe('Text custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[0]} /> + ); + + const modifiedProps = { + ...props, + data: customFieldsConfigurationMock[0], + }; + + appMockRender.render(<CommonFlyout {...modifiedProps}>{newRenderBody}</CommonFlyout>); + + 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-required')).toHaveAttribute('checked'); + expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].defaultValue + ); + }); + + it('shows an error if default value is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) + ); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(DEFAULT_VALUE.toLowerCase(), MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); + + describe('Toggle custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('calls onSaveField with the correct default value when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('toggle-custom-field-required')); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[1]} /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[1].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute( + 'checked' + ); + expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + }); + + describe('TemplateFlyout', () => { + const currentConfiguration = { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }; + + const renderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={null} + connectors={connectorsMock} + currentConfiguration={currentConfiguration} + onChange={onChange} + /> + ); + + it('should render template form in flyout', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('should render all fields with details', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const newConfiguration = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + }; + + appMockRender = createAppMockRenderer({ license }); + + appMockRender.render( + <CommonFlyout {...props}> + {({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={[]} + currentConfiguration={newConfiguration} + onChange={onChange} + /> + )} + </CommonFlyout> + ); + + // template fields + expect(await screen.findByTestId('template-name-input')).toHaveValue('Fourth test template'); + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a fourth test template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('foo'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('bar'); + + const caseTitle = await screen.findByTestId('caseTitle'); + expect(within(caseTitle).getByTestId('input')).toHaveValue('Case with sample template 4'); + + const caseDescription = await screen.findByTestId('caseDescription'); + expect(within(caseDescription).getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( + 'case desc' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + expect(within(caseCategory).getByRole('combobox')).toHaveTextContent(''); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('sample-4'); + + expect(await screen.findByTestId('case-severity-selection-low')).toBeInTheDocument(); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + expect(await within(assigneesComboBox).findByTestId('comboBoxInput')).toHaveTextContent( + 'Damaged Raccoon' + ); + + // custom fields + expect( + await screen.findByTestId('first_custom_field_key-text-create-custom-field') + ).toHaveValue('this is a text field value'); + + // connector + expect(await screen.findByTestId('dropdown-connector-no-connector')).toBeInTheDocument(); + }); + + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + const templateTags = await screen.findByTestId('template-tags'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'Template description', + name: 'Template name', + tags: ['foo'], + }); + }); + }); + + it('calls onSaveField with case fields correctly', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: null, + }} + connectors={[]} + currentConfiguration={currentConfiguration} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + title: 'Case using template', + description: 'This is a case description', + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + }); + }); + }); + + it('calls onSaveField form with custom fields correctly', async () => { + const newConfig = { ...currentConfiguration, customFields: customFieldsConfigurationMock }; + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: null, + }} + connectors={[]} + currentConfiguration={newConfig} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + const textCustomField = await screen.findByTestId( + `${customFieldsConfigurationMock[0].key}-text-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'this is a sample text!'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + connector: { + id: 'none', + name: 'none', + type: '.none', + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is a sample text!', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], + }, + }); + }); + }); + + it('calls onSaveField form with connector fields correctly', async () => { + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + + const connector = { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }; + + const newConfig = { + ...currentConfiguration, + connector, + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + description: 'test description', + caseFields: { connector }, + }} + connectors={connectorsMock} + currentConfiguration={newConfig} + onChange={onChange} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + description: 'test description', + tags: [], + caseFields: { + customFields: [], + connector: { + ...connector, + fields: { + urgency: '1', + severity: null, + impact: null, + category: 'software', + subcategory: null, + }, + }, + settings: { + syncAlerts: true, + }, + }, + }); + }); + }); + + it('calls onSaveField with edited fields correctly', async () => { + const newConfig = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={connectorsMock} + currentConfiguration={newConfig} + onChange={onChange} + isEditMode={true} + /> + ); + + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.clear(within(caseTitle).getByTestId('input')); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Updated case using template'); + + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + userEvent.clear(customField); + userEvent.paste(customField, 'Updated custom field value'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'Updated custom field value', + }, + ], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Updated case using template', + }, + description: 'This is a fourth test template', + key: 'test_template_4', + name: 'Template name', + tags: ['foo', 'bar'], + }); + }); + }); + + it('shows error when template name is empty', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).not.toHaveBeenCalled(); + }); + + expect(await screen.findByText('A Template name is required.')).toBeInTheDocument(); + }); + + it('shows error if template name is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), message); + + expect( + await screen.findByText(i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH)) + ).toBeInTheDocument(); + }); + + it('shows error if template description is too long', async () => { + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); + + const message = 'z'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx new file mode 100644 index 0000000000000..37d16d01e5681 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { FormHook, FormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; + +import * as i18n from './translations'; + +export interface FormState<T extends FormData = FormData, I extends FormData = T> { + isValid: boolean | undefined; + submit: FormHook<T, I>['submit']; +} + +export interface FlyOutBodyProps<T extends FormData = FormData, I extends FormData = T> { + onChange: (state: FormState<T, I>) => void; +} + +export interface FlyoutProps<T extends FormData = FormData, I extends FormData = T> { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: I) => void; + renderHeader: () => React.ReactNode; + children: ({ onChange }: FlyOutBodyProps<T, I>) => React.ReactNode; +} + +export const CommonFlyout = <T extends FormData = FormData, I extends FormData = T>({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, + renderHeader, + children, +}: FlyoutProps<T, I>) => { + const [formState, setFormState] = useState<FormState<T, I>>({ + isValid: undefined, + submit: async () => ({ + isValid: false, + data: {} as T, + }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + /** + * The serializer transforms the data + * from the form format to the backend + * format. The I generic is the correct + * format of the data. + */ + onSaveField(data as unknown as I); + } + }, [onSaveField, submit]); + + /** + * The children will call setFormState which in turn will make the parent + * to rerender which in turn will rerender the children etc. + * To avoid an infinitive loop we need to memoize the children. + */ + const memoizedChildren = useMemo( + () => + children({ + onChange: setFormState, + }), + [children] + ); + + return ( + <EuiFlyout onClose={onCloseFlyout} data-test-subj="common-flyout"> + <EuiFlyoutHeader hasBorder data-test-subj="common-flyout-header"> + <EuiTitle size="s"> + <h3 id="flyoutTitle">{renderHeader()}</h3> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody>{memoizedChildren}</EuiFlyoutBody> + <EuiFlyoutFooter data-test-subj={'common-flyout-footer'}> + <EuiFlexGroup justifyContent="flexStart"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={onCloseFlyout} + data-test-subj={'common-flyout-cancel'} + disabled={disabled} + isLoading={isLoading} + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButton + fill + onClick={handleSaveField} + data-test-subj={'common-flyout-save'} + disabled={disabled} + isLoading={isLoading} + > + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </EuiFlyoutFooter> + </EuiFlyout> + ); +}; + +CommonFlyout.displayName = 'CommonFlyout'; 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 ba3e7850533c9..b424b2ca62fc0 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 @@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; -import { customFieldsConfigurationMock } from '../../containers/mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../../containers/mock'; import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -36,6 +36,7 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useLicense } from '../../common/use_license'; +import * as i18n from './translations'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); @@ -78,7 +79,11 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { @@ -126,7 +131,11 @@ describe('ConfigureCases', () => { }, })); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { wrappingComponent: TestProviders, @@ -425,6 +434,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-user', customFields: [], + templates: [], id: '', version: '', }); @@ -521,6 +531,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-pushing', customFields: [], + templates: [], id: '', version: '', }); @@ -688,7 +699,7 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(screen.getByText('Delete')); @@ -706,6 +717,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -729,11 +741,11 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) ); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -756,6 +768,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -767,7 +780,7 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); }); it('closes fly out for when click on cancel', async () => { @@ -775,12 +788,12 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + userEvent.click(screen.getByTestId('common-flyout-cancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); it('closes fly out for when click on save field', async () => { @@ -788,11 +801,11 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -812,20 +825,237 @@ describe('ConfigureCases', () => { required: false, }, ], + templates: [], id: '', version: '', }); }); expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + }); + + describe('templates', () => { + let appMockRender: AppMockRenderer; + const persistCaseConfigure = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false, isAtLeastGold: () => true }); + }); + + it('should render template section', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('should render template form in flyout', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent( + i18n.CREATE_TEMPLATE + ); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('should add template', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: customFieldsConfigurationMock, + templates: [ + { + key: expect.anything(), + name: 'Template name', + description: 'Template description', + tags: [], + caseFields: { + title: 'Case using template', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: customFieldsConfigurationMock[0].type, + value: customFieldsConfigurationMock[0].defaultValue, + }, + { + key: customFieldsConfigurationMock[1].key, + type: customFieldsConfigurationMock[1].type, + value: customFieldsConfigurationMock[1].defaultValue, + }, + { + key: customFieldsConfigurationMock[3].key, + type: customFieldsConfigurationMock[3].type, + value: false, // when no default value for toggle, we set it to false + }, + ], + }, + }, + ], + id: '', + version: '', + }); + }); + + expect(screen.getByTestId('templates-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + + it('should delete a template', async () => { + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: templatesConfigurationMock, + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-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: [], + templates: [ + { ...templatesConfigurationMock[1] }, + { ...templatesConfigurationMock[2] }, + { ...templatesConfigurationMock[3] }, + { ...templatesConfigurationMock[4] }, + ], + id: '', + version: '', + }); + }); + }); + + it('should update a template', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: [templatesConfigurationMock[0], templatesConfigurationMock[3]], + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Updated template name'); + + userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [ + { + ...templatesConfigurationMock[0], + name: 'Updated template name', + tags: [], + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + }, + { ...templatesConfigurationMock[3] }, + ], + id: '', + version: '', + }); + }); }); }); describe('rendering with license limitations', () => { let appMockRender: AppMockRenderer; let persistCaseConfigure: jest.Mock; - beforeEach(() => { // Default setup jest.clearAllMocks(); 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 d33726d7ccdfe..1003a10646e8c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable complexity */ + import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; @@ -22,7 +24,7 @@ import { 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 type { CustomFieldConfiguration, TemplateConfiguration } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; @@ -32,17 +34,20 @@ import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils'; import * as i18n from './translations'; -import { getConnectorById } from '../utils'; +import { getConnectorById, addOrReplaceField } from '../utils'; 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 { CommonFlyout } from './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'; +import { Templates } from '../templates'; +import type { TemplateFormProps } from '../templates/types'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; const sectionWrapperCss = css` box-sizing: content-box; @@ -58,6 +63,11 @@ const getFormWrapperCss = (euiTheme: EuiThemeComputed<{}>) => css` } `; +interface Flyout { + type: 'addConnector' | 'editConnector' | 'customField' | 'template'; + visible: boolean; +} + export const ConfigureCases: React.FC = React.memo(() => { const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; @@ -66,28 +76,30 @@ export const ConfigureCases: React.FC = React.memo(() => { const hasMinimumLicensePermissions = license.isAtLeastGold(); const [connectorIsValid, setConnectorIsValid] = useState(true); - const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); - const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false); + const [flyOutVisibility, setFlyOutVisibility] = useState<Flyout | null>(null); const [editedConnectorItem, setEditedConnectorItem] = useState<ActionConnectorTableItem | null>( null ); - const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState<boolean>(false); const [customFieldToEdit, setCustomFieldToEdit] = useState<CustomFieldConfiguration | null>(null); + const [templateToEdit, setTemplateToEdit] = useState<TemplateConfiguration | null>(null); const { euiTheme } = useEuiTheme(); const { - data: { - id: configurationId, - version: configurationVersion, - closureType, - connector, - mappings, - customFields, - }, + data: currentConfiguration, isLoading: loadingCaseConfigure, refetch: refetchCaseConfigure, } = useGetCaseConfiguration(); + const { + id: configurationId, + version: configurationVersion, + closureType, + connector, + mappings, + customFields, + templates, + } = currentConfiguration; + const { mutate: persistCaseConfigure, mutateAsync: persistCaseConfigureAsync, @@ -95,7 +107,6 @@ export const ConfigureCases: React.FC = React.memo(() => { } = usePersistConfiguration(); const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; - const { isLoading: isLoadingConnectors, data: connectors = [], @@ -125,6 +136,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -135,6 +147,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigureAsync, closureType, customFields, + templates, configurationId, configurationVersion, onConnectorUpdated, @@ -148,20 +161,23 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { - setEditFlyoutVisibility(true); + setFlyOutVisibility({ type: 'editConnector', visible: true }); }, []); const onCloseAddFlyout = useCallback( - () => setAddFlyoutVisibility(false), - [setAddFlyoutVisibility] + () => setFlyOutVisibility({ type: 'addConnector', visible: false }), + [setFlyOutVisibility] ); - const onCloseEditFlyout = useCallback(() => setEditFlyoutVisibility(false), []); + const onCloseEditFlyout = useCallback( + () => setFlyOutVisibility({ type: 'editConnector', visible: false }), + [] + ); const onChangeConnector = useCallback( (id: string) => { if (id === 'add-connector') { - setAddFlyoutVisibility(true); + setFlyOutVisibility({ type: 'addConnector', visible: true }); return; } @@ -173,6 +189,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -182,6 +199,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure, closureType, customFields, + templates, configurationId, configurationVersion, ] @@ -192,12 +210,20 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector, customFields, + templates, id: configurationId, version: configurationVersion, closureType: type, }); }, - [configurationId, configurationVersion, connector, customFields, persistCaseConfigure] + [ + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] ); useEffect(() => { @@ -225,7 +251,7 @@ export const ConfigureCases: React.FC = React.memo(() => { const ConnectorAddFlyout = useMemo( () => - addFlyoutVisible + flyOutVisibility?.type === 'addConnector' && flyOutVisibility?.visible ? triggersActionsUi.getAddConnectorFlyout({ onClose: onCloseAddFlyout, featureId: CasesConnectorFeatureId, @@ -233,12 +259,12 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [addFlyoutVisible] + [flyOutVisibility] ); const ConnectorEditFlyout = useMemo( () => - editedConnectorItem && editFlyoutVisible + editedConnectorItem && flyOutVisibility?.type === 'editConnector' && flyOutVisibility?.visible ? triggersActionsUi.getEditConnectorFlyout({ connector: editedConnectorItem, onClose: onCloseEditFlyout, @@ -246,20 +272,31 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [connector.id, editedConnectorItem, editFlyoutVisible] + [connector.id, editedConnectorItem, flyOutVisibility] ); - const onAddCustomFields = useCallback(() => { - setCustomFieldFlyoutVisibility(true); - }, [setCustomFieldFlyoutVisibility]); - const onDeleteCustomField = useCallback( (key: string) => { const remainingCustomFields = customFields.filter((field) => field.key !== key); + // delete the same custom field from each template as well + const templatesWithRemainingCustomFields = templates.map((template) => { + const templateCustomFields = + template.caseFields?.customFields?.filter((field) => field.key !== key) ?? []; + + return { + ...template, + caseFields: { + ...template.caseFields, + customFields: [...templateCustomFields], + }, + }; + }); + persistCaseConfigure({ connector, customFields: [...remainingCustomFields], + templates: [...templatesWithRemainingCustomFields], id: configurationId, version: configurationVersion, closureType, @@ -271,6 +308,7 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, ] ); @@ -282,28 +320,30 @@ export const ConfigureCases: React.FC = React.memo(() => { if (selectedCustomField) { setCustomFieldToEdit(selectedCustomField); } - setCustomFieldFlyoutVisibility(true); + setFlyOutVisibility({ type: 'customField', visible: true }); }, - [setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields] + [setFlyOutVisibility, setCustomFieldToEdit, customFields] ); - const onCloseAddFieldFlyout = useCallback(() => { - setCustomFieldFlyoutVisibility(false); + const onCloseCustomFieldFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); - }, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]); + }, [setFlyOutVisibility, setCustomFieldToEdit]); + + const onCustomFieldSave = useCallback( + (data: CustomFieldConfiguration) => { + const updatedCustomFields = addOrReplaceField(customFields, data); - const onSaveCustomField = useCallback( - (customFieldData: CustomFieldConfiguration) => { - const updatedFields = addOrReplaceCustomField(customFields, customFieldData); persistCaseConfigure({ connector, - customFields: updatedFields, + customFields: updatedCustomFields, + templates, id: configurationId, version: configurationVersion, closureType, }); - setCustomFieldFlyoutVisibility(false); + setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); }, [ @@ -312,24 +352,124 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, ] ); - const CustomFieldAddFlyout = customFieldFlyoutVisible ? ( - <CustomFieldFlyout - isLoading={loadingCaseConfigure || isPersistingConfiguration} - disabled={ - !permissions.create || - !permissions.update || - loadingCaseConfigure || - isPersistingConfiguration + const onDeleteTemplate = useCallback( + (key: string) => { + const remainingTemplates = templates.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + customFields, + templates: [...remainingTemplates], + id: configurationId, + version: configurationVersion, + closureType, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const onEditTemplate = useCallback( + (key: string) => { + const selectedTemplate = templates.find((item) => item.key === key); + + if (selectedTemplate) { + setTemplateToEdit(selectedTemplate); } - customField={customFieldToEdit} - onCloseFlyout={onCloseAddFieldFlyout} - onSaveField={onSaveCustomField} - /> - ) : null; + setFlyOutVisibility({ type: 'template', visible: true }); + }, + [setFlyOutVisibility, setTemplateToEdit, templates] + ); + + const onCloseTemplateFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + }, [setFlyOutVisibility, setTemplateToEdit]); + + const onTemplateSave = useCallback( + (data: TemplateConfiguration) => { + const updatedTemplates = addOrReplaceField(templates, data); + + persistCaseConfigure({ + connector, + customFields, + templates: updatedTemplates, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const AddOrEditCustomFieldFlyout = + flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( + <CommonFlyout<CustomFieldConfiguration> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseCustomFieldFlyout} + onSaveField={onCustomFieldSave} + renderHeader={() => <span>{i18n.ADD_CUSTOM_FIELD}</span>} + > + {({ onChange }) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldToEdit} /> + )} + </CommonFlyout> + ) : null; + + const AddOrEditTemplateFlyout = + flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( + <CommonFlyout<TemplateFormProps, TemplateConfiguration> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseTemplateFlyout} + onSaveField={onTemplateSave} + renderHeader={() => <span>{i18n.CREATE_TEMPLATE}</span>} + > + {({ onChange }) => ( + <TemplateForm + initialValue={templateToEdit} + connectors={connectors ?? []} + currentConfiguration={currentConfiguration} + isEditMode={Boolean(templateToEdit)} + onChange={onChange} + /> + )} + </CommonFlyout> + ) : null; return ( <EuiPageSection restrictWidth={true}> @@ -397,16 +537,34 @@ export const ConfigureCases: React.FC = React.memo(() => { customFields={customFields} isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} - handleAddCustomField={onAddCustomFields} + handleAddCustomField={() => + setFlyOutVisibility({ type: 'customField', visible: true }) + } handleDeleteCustomField={onDeleteCustomField} handleEditCustomField={onEditCustomField} /> </EuiFlexItem> </div> + + <EuiSpacer size="xl" /> + + <div css={sectionWrapperCss}> + <EuiFlexItem grow={false}> + <Templates + templates={templates} + isLoading={isLoadingCaseConfiguration} + disabled={isLoadingCaseConfiguration} + onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + onEditTemplate={onEditTemplate} + onDeleteTemplate={onDeleteTemplate} + /> + </EuiFlexItem> + </div> <EuiSpacer size="xl" /> {ConnectorAddFlyout} {ConnectorEditFlyout} - {CustomFieldAddFlyout} + {AddOrEditCustomFieldFlyout} + {AddOrEditTemplateFlyout} </div> </EuiPageBody> </EuiPageSection> 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 e10f6fcad2fb9..08c83c9564f1e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -160,3 +160,14 @@ export const CASES_WEBHOOK_MAPPINGS = i18n.translate( 'Webhook - Case Management field mappings are configured in the connector settings in the third-party REST API JSON.', } ); + +export const ADD_CUSTOM_FIELD = i18n.translate( + 'xpack.cases.configureCases.customFields.addCustomField', + { + defaultMessage: 'Add field', + } +); + +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.flyoutTitle', { + defaultMessage: 'Create template', +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts index 2177ea7af81d9..a46b85f756941 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -51,7 +51,7 @@ export const setThirdPartyToMapping = ( export const getNoneConnector = (): CaseConnector => ({ id: 'none', name: 'none', - type: ConnectorTypes.none, + type: ConnectorTypes.none as const, fields: null, }); diff --git a/x-pack/plugins/cases/public/components/connectors/constants.ts b/x-pack/plugins/cases/public/components/connectors/constants.ts index 486698330d860..1443b6ae49b05 100644 --- a/x-pack/plugins/cases/public/components/connectors/constants.ts +++ b/x-pack/plugins/cases/public/components/connectors/constants.ts @@ -15,6 +15,8 @@ export const connectorsQueriesKeys = { [...connectorsQueriesKeys.jira, connectorId, 'getIssueType'] as const, jiraGetIssues: (connectorId: string, query: string) => [...connectorsQueriesKeys.jira, connectorId, 'getIssues', query] as const, + jiraGetIssue: (connectorId: string, id: string) => + [...connectorsQueriesKeys.jira, connectorId, 'getIssue', id] as const, resilientGetIncidentTypes: (connectorId: string) => [...connectorsQueriesKeys.resilient, connectorId, 'getIncidentTypes'] as const, resilientGetSeverity: (connectorId: string) => diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx index 743ecac4cdc91..5d172539ea29a 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -13,6 +13,7 @@ import userEvent from '@testing-library/user-event'; import { connector, issues } from '../mock'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import { useGetIssue } from './use_get_issue'; import Fields from './case_fields'; import { useGetIssues } from './use_get_issues'; import type { AppMockRenderer } from '../../../common/mock'; @@ -22,11 +23,13 @@ import { MockFormWrapperComponent } from '../test_utils'; jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); jest.mock('./use_get_issues'); +jest.mock('./use_get_issue'); jest.mock('../../../common/lib/kibana'); const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const useGetIssuesMock = useGetIssues as jest.Mock; +const useGetIssueMock = useGetIssue as jest.Mock; describe('Jira Fields', () => { const useGetIssueTypesResponse = { @@ -84,6 +87,12 @@ describe('Jira Fields', () => { data: { data: issues }, }; + const useGetIssueResponse = { + isLoading: false, + isFetching: false, + data: { data: issues[0] }, + }; + let appMockRenderer: AppMockRenderer; beforeEach(() => { @@ -91,6 +100,7 @@ describe('Jira Fields', () => { useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); useGetIssuesMock.mockReturnValue(useGetIssuesResponse); + useGetIssueMock.mockReturnValue(useGetIssueResponse); jest.clearAllMocks(); }); @@ -237,6 +247,38 @@ describe('Jira Fields', () => { expect(await screen.findByTestId('prioritySelect')).toHaveValue('Low'); }); + it('sets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + }); + + it('resets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + const checkbox = within(await screen.findByTestId('search-parent-issues')).getByTestId( + 'comboBoxSearchInput' + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('comboBoxClearButton')); + + expect(checkbox).toHaveValue(''); + }); + it('should submit Jira connector', async () => { appMockRenderer.render( <MockFormWrapperComponent fields={fields}> diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 27df975ac5864..c4089c7f14c60 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -6,83 +6,140 @@ */ import React, { useState, memo } from 'react'; +import { isEmpty } from 'lodash'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFormRow } from '@elastic/eui'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { getFieldValidityAndErrorMessage, UseField, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useIsUserTyping } from '../../../common/use_is_user_typing'; import { useKibana } from '../../../common/lib/kibana'; import type { ActionConnector } from '../../../../common/types/domain'; import { useGetIssues } from './use_get_issues'; import * as i18n from './translations'; +import { useGetIssue } from './use_get_issue'; + +interface FieldProps { + field: FieldHook<string>; + options: Array<EuiComboBoxOptionOption<string>>; + isLoading: boolean; + onSearchComboChange: (value: string) => void; +} interface Props { actionConnector?: ActionConnector; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { - const [query, setQuery] = useState<string | null>(null); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - [] +const SearchIssuesFieldComponent: React.FC<FieldProps> = ({ + field, + options, + isLoading, + onSearchComboChange, +}) => { + const { value: parent } = field; + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const selectedOptions = [parent] + .map((currentParent: string) => { + const selectedParent = options.find((issue) => issue.value === currentParent); + + if (selectedParent) { + return selectedParent; + } + + return null; + }) + .filter((value): value is EuiComboBoxOptionOption<string> => value != null); + + const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { + field.setValue(changedOptions.length ? changedOptions[0].value ?? '' : ''); + }; + + return ( + <EuiFormRow + id="indexConnectorSelectSearchBox" + fullWidth + label={i18n.PARENT_ISSUE} + isInvalid={isInvalid} + error={errorMessage} + > + <EuiComboBox + fullWidth + singleSelection + async + placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} + aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} + isLoading={isLoading} + isInvalid={isInvalid} + noSuggestions={!options.length} + options={options} + data-test-subj="search-parent-issues" + data-testid="search-parent-issues" + selectedOptions={selectedOptions} + onChange={onChangeComboBox} + onSearchChange={onSearchComboChange} + /> + </EuiFormRow> ); +}; +SearchIssuesFieldComponent.displayName = 'SearchIssuesField'; + +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { const { http } = useKibana().services; + const [{ fields }] = useFormData<{ fields?: { parent: string } }>({ + watch: ['fields.parent'], + }); + + const [query, setQuery] = useState<string | null>(null); + const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const { isFetching: isLoadingIssues, data: issuesData } = useGetIssues({ http, actionConnector, query, + onDebounce, + }); + + const { isFetching: isLoadingIssue, data: issueData } = useGetIssue({ + http, + actionConnector, + id: fields?.parent ?? '', }); const issues = issuesData?.data ?? []; + const issue = issueData?.data ? [issueData.data] : []; + + const onSearchComboChange = (value: string) => { + if (!isEmpty(value)) { + setQuery(value); + } - const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); + onContentChange(value); + }; + + const isLoading = isUserTyping || isLoadingIssues || isLoadingIssue; + const options = [...issues, ...issue].map((_issue) => ({ + label: _issue.title, + value: _issue.key, + })); return ( - <UseField path="fields.parent"> - {(field) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const onSearchChange = (searchVal: string) => { - setQuery(searchVal); - }; - - const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { - setSelectedOptions(changedOptions); - field.setValue(changedOptions[0].value ?? ''); - }; - - return ( - <EuiFormRow - id="indexConnectorSelectSearchBox" - fullWidth - label={i18n.PARENT_ISSUE} - isInvalid={isInvalid} - error={errorMessage} - > - <EuiComboBox - fullWidth - singleSelection - async - placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} - aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} - isLoading={isLoadingIssues} - isInvalid={isInvalid} - noSuggestions={!options.length} - options={options} - data-test-subj="search-parent-issues" - data-testid="search-parent-issues" - selectedOptions={selectedOptions} - onChange={onChangeComboBox} - onSearchChange={onSearchChange} - /> - </EuiFormRow> - ); + <UseField<string> + path="fields.parent" + component={SearchIssuesFieldComponent} + componentProps={{ + isLoading, + onSearchComboChange, + options, }} - </UseField> + /> ); }; + SearchIssuesComponent.displayName = 'SearchIssues'; export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx new file mode 100644 index 0000000000000..876738025e6a8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { useKibana, useToasts } from '../../../common/lib/kibana'; +import { connector as actionConnector } from '../mock'; +import { useGetIssue } from './use_get_issue'; +import * as api from './api'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; + +describe('useGetIssue', () => { + const { http } = useKibanaMock().services; + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'getIssue'); + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(spy).toHaveBeenCalledWith({ + http, + signal: expect.anything(), + connectorId: actionConnector.id, + id: 'RJ-107', + }); + }); + + it('does not call the api when the connector is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('does not call the api when the id is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: '', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('calls addError when the getIssue api throws an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isError); + + expect(addError).toHaveBeenCalled(); + }); + + it('calls addError when the getIssue api returns successfully but contains an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockResolvedValue({ + status: 'error', + message: 'Error message', + actionId: 'test', + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx new file mode 100644 index 0000000000000..ed3bfcf61f2f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.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 type { HttpSetup } from '@kbn/core/public'; +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { useQuery } from '@tanstack/react-query'; +import { isEmpty } from 'lodash'; +import type { ActionConnector } from '../../../../common/types/domain'; +import { getIssue } from './api'; +import type { Issue } from './types'; +import * as i18n from './translations'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import type { ServerError } from '../../../types'; +import { connectorsQueriesKeys } from '../constants'; + +interface Props { + http: HttpSetup; + id: string; + actionConnector?: ActionConnector; +} + +export const useGetIssue = ({ http, actionConnector, id }: Props) => { + const { showErrorToast } = useCasesToast(); + return useQuery<ActionTypeExecutorResult<Issue>, ServerError>( + connectorsQueriesKeys.jiraGetIssue(actionConnector?.id ?? '', id), + ({ signal }) => { + return getIssue({ + http, + signal, + connectorId: actionConnector?.id ?? '', + id, + }); + }, + { + enabled: Boolean(actionConnector && !isEmpty(id)), + staleTime: 60 * 1000, // one minute + onSuccess: (res) => { + if (res.status && res.status === 'error') { + showErrorToast(new Error(i18n.GET_ISSUE_API_ERROR(id)), { + title: i18n.GET_ISSUE_API_ERROR(id), + toastMessage: `${res.serviceMessage ?? res.message}`, + }); + } + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.GET_ISSUE_API_ERROR(id) }); + }, + } + ); +}; + +export type UseGetIssueTypes = ReturnType<typeof useGetIssue>; diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx index 037fcc6bb8d8e..01f4ad0a3edb3 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx @@ -10,7 +10,8 @@ import useDebounce from 'react-use/lib/useDebounce'; import type { HttpSetup } from '@kbn/core/public'; import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import { useQuery } from '@tanstack/react-query'; -import { isEmpty } from 'lodash'; +import { isEmpty, noop } from 'lodash'; +import { SEARCH_DEBOUNCE_MS } from '../../../../common/constants'; import type { ActionConnector } from '../../../../common/types/domain'; import { getIssues } from './api'; import type { Issues } from './types'; @@ -23,16 +24,16 @@ interface Props { http: HttpSetup; query: string | null; actionConnector?: ActionConnector; + onDebounce?: () => void; } -const SEARCH_DEBOUNCE_MS = 500; - -export const useGetIssues = ({ http, actionConnector, query }: Props) => { +export const useGetIssues = ({ http, actionConnector, query, onDebounce = noop }: Props) => { const [debouncedQuery, setDebouncedQuery] = useState(query); useDebounce( () => { setDebouncedQuery(query); + onDebounce(); }, SEARCH_DEBOUNCE_MS, [query] diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx index e8260a69a3301..ee7538543ec41 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -77,12 +77,15 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = field.setValue(changedOptions.map((option) => option.value as string)); }; - const selectedOptions = (field.value ?? []).map((incidentType) => ({ - value: incidentType, - label: - (allIncidentTypes ?? []).find((type) => incidentType === type.id.toString())?.name ?? - '', - })); + const selectedOptions = + field.value && allIncidentTypes?.length + ? field.value.map((incidentType) => ({ + value: incidentType, + label: + allIncidentTypes.find((type) => incidentType === type.id.toString())?.name ?? + '', + })) + : []; return ( <EuiFormRow diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx deleted file mode 100644 index 1cf7c82075136..0000000000000 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ /dev/null @@ -1,210 +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 type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { mount } from 'enzyme'; -import { act, waitFor } from '@testing-library/react'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiComboBox } from '@elastic/eui'; - -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 { Connector } from './connector'; -import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; -import { useGetSeverity } from '../connectors/resilient/use_get_severity'; -import { useGetChoices } from '../connectors/servicenow/use_get_choices'; -import { incidentTypes, severity, choices } from '../connectors/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; -import type { AppMockRenderer } from '../../common/mock'; -import { - noConnectorsCasePermission, - createAppMockRenderer, - TestProviders, -} from '../../common/mock'; -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_get_case_configuration'); - -const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; -const useGetSeverityMock = useGetSeverity as jest.Mock; -const useGetChoicesMock = useGetChoices as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; - -const useGetIncidentTypesResponse = { - isLoading: false, - incidentTypes, -}; - -const useGetSeverityResponse = { - isLoading: false, - severity, -}; - -const useGetChoicesResponse = { - isLoading: false, - choices, -}; - -const defaultProps = { - connectors: connectorsMock, - isLoading: false, - isLoadingConnectors: false, -}; - -describe('Connector', () => { - let appMockRender: AppMockRenderer; - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { connectorId: connectorsMock[0].id, fields: null }, - schema: { - connectorId: schema.connectorId, - fields: schema.fields, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; - }; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); - useGetSeverityMock.mockReturnValue(useGetSeverityResponse); - useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); - }); - - it('it renders', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - // Selected connector is set to none so no fields should be displayed - expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy(); - }); - - it('it is disabled and loading when isLoadingConnectors=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoadingConnectors: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it('it is disabled and loading when isLoading=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoading: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it(`it should change connector`, async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); - }); - - act(() => { - ( - wrapper.find(EuiComboBox).props() as unknown as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - } - ).onChange([{ value: '19', label: 'Denial of Service' }]); - }); - - act(() => { - wrapper - .find('select[data-test-subj="severitySelect"]') - .first() - .simulate('change', { - target: { value: '4' }, - }); - }); - - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ - connectorId: 'resilient-2', - fields: { incidentTypes: ['19'], severityCode: '4' }, - }); - }); - }); - - it('shows the actions permission message if the user does not have read access to actions', async () => { - appMockRender.coreStart.application.capabilities = { - ...appMockRender.coreStart.application.capabilities, - actions: { save: false, show: false }, - }; - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); - - it('shows the actions permission message if the user does not have access to case connector', async () => { - appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx deleted file mode 100644 index 28cebde65db27..0000000000000 --- a/x-pack/plugins/cases/public/components/create/custom_fields.tsx +++ /dev/null @@ -1,85 +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, { useMemo } from 'react'; -import { sortBy } from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; - -import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { CasesConfigurationUI } from '../../../common/ui'; -import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; -import * as i18n from './translations'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { getConfigurationByOwner } from '../../containers/configure/utils'; - -interface Props { - isLoading: boolean; -} - -const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { - const { owner } = useCasesContext(); - const [{ selectedOwner }] = useFormData<{ selectedOwner: string }>({ watch: ['selectedOwner'] }); - const { data: configurations, isLoading: isLoadingCaseConfiguration } = - useGetAllCaseConfigurations(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const customFieldsConfiguration = useMemo( - () => - getConfigurationByOwner({ - configurations, - owner: configurationOwner, - }).customFields ?? [], - [configurations, configurationOwner] - ); - - 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 ( - <CreateComponent - isLoading={isLoading || isLoadingCaseConfiguration} - customFieldConfiguration={customField} - key={customField.key} - /> - ); - } - ); - - if (!customFieldsConfiguration.length) { - return null; - } - - return ( - <EuiFlexGroup direction="column" gutterSize="s"> - <EuiText size="m"> - <h3>{i18n.ADDITIONAL_FIELDS}</h3> - </EuiText> - <EuiSpacer size="xs" /> - <EuiFlexItem data-test-subj="create-case-custom-fields">{customFieldsComponents}</EuiFlexItem> - </EuiFlexGroup> - ); -}; - -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 b5b3f7bf7b677..885e25e959ac9 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -5,48 +5,44 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; import React from 'react'; -import { mount } from 'enzyme'; -import { act, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { within, fireEvent, waitFor, screen } from '@testing-library/react'; 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, customFieldsConfigurationMock } from '../../containers/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; import type { CreateCaseFormProps } from './form'; import { CreateCaseForm } from './form'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; -import { TestProviders } from '../../common/mock'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); jest.mock('../../containers/configure/use_get_all_case_configurations'); +jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); +jest.mock('../../containers/user_profiles/use_get_current_user_profile'); 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 useGetSupportedActionConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +const useSuggestUserProfilesMock = useSuggestUserProfiles as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; const casesFormProps: CreateCaseFormProps = { onCancel: jest.fn(), @@ -54,36 +50,18 @@ const casesFormProps: CreateCaseFormProps = { }; describe('CreateCaseForm', () => { - let globalForm: FormHook; - const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; - - const MockHookWrapperComponent: FC< - PropsWithChildren<{ - testProviderProps?: unknown; - }> - > = ({ children, testProviderProps = {} }) => { - const { form } = useForm<FormProps>({ - defaultValue: initialCaseValue, - options: { stripEmptyFields: false }, - schema, - }); - - globalForm = form; - - return ( - // @ts-expect-error ts upgrade v4.7.4 - <TestProviders {...testProviderProps}> - <Form form={form}>{children}</Form> - </TestProviders> - ); - }; + const draftStorageKey = 'cases.caseView.createCase.description.markdownEditor'; + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']); useGetTagsMock.mockReturnValue({ data: ['test'] }); - useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); + useGetSupportedActionConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); + useSuggestUserProfilesMock.mockReturnValue({ data: userProfiles, isLoading: false }); + useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); }); afterEach(() => { @@ -91,136 +69,86 @@ describe('CreateCaseForm', () => { }); it('renders with steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('case-creation-form-steps')).toBeInTheDocument(); }); it('renders without steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} withSteps={false} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} withSteps={false} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy(); + expect(screen.queryByText('case-creation-form-steps')).not.toBeInTheDocument(); }); it('renders all form fields except case selection', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(screen.queryByText('caseOwnerSelector')).not.toBeInTheDocument(); }); it('renders all form fields including case selection if has permissions and no owner', async () => { - const wrapper = mount( - <MockHookWrapperComponent testProviderProps={{ owner: [] }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy(); + appMockRenderer = createAppMockRenderer({ owner: [] }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); }); it('does not render solution picker when only one owner is available', async () => { useAvailableOwnersMock.mockReturnValue(['securitySolution']); - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + expect(screen.queryByTestId('caseOwnerSelector')).not.toBeInTheDocument(); }); - it('hides the sync alerts toggle', () => { - const { queryByText } = render( - <MockHookWrapperComponent testProviderProps={{ features: { alerts: { sync: false } } }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + it('hides the sync alerts toggle', async () => { + appMockRenderer = createAppMockRenderer({ features: { alerts: { sync: false } } }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(queryByText('Sync alert')).not.toBeInTheDocument(); - }); - - it('should render spinner when loading', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); - - await act(async () => { - globalForm.setFieldValue('title', 'title'); - globalForm.setFieldValue('description', 'description'); - await wrapper.find(`button[data-test-subj="create-case-submit"]`).simulate('click'); - wrapper.update(); - }); - - expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); + expect(screen.queryByText('Sync alert')).not.toBeInTheDocument(); }); it('should not render the assignees on basic license', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(result.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); }); - it('should render the assignees on platinum license', () => { + it('should render the assignees on platinum license', async () => { const license = licensingMock.createLicense({ license: { type: 'platinum' }, }); - const result = render( - <MockHookWrapperComponent testProviderProps={{ license }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); - it('should not prefill the form when no initialValue provided', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> + it('should not prefill the form when no initialValue provided', async () => { + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); expect(titleInput).toHaveValue(''); expect(descriptionInput).toHaveValue(''); }); - it('should render custom fields when available', () => { + it('should render custom fields when available', async () => { useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, data: [ @@ -231,70 +159,62 @@ describe('CreateCaseForm', () => { ], })); - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); for (const item of customFieldsConfigurationMock) { expect( - result.getByTestId(`${item.key}-${item.type}-create-custom-field`) + await screen.findByTestId(`${item.key}-${item.type}-create-custom-field`) ).toBeInTheDocument(); } }); - it('should prefill the form when provided with initialValue', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should prefill the form when provided with initialValue', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' + ); expect(titleInput).toHaveValue('title'); expect(descriptionInput).toHaveValue('description'); }); describe('draft comment ', () => { - it('should clear session storage key on cancel', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on cancel', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const cancelBtn = result.getByTestId('create-case-cancel'); + const cancelBtn = await screen.findByTestId('create-case-cancel'); fireEvent.click(cancelBtn); - fireEvent.click(result.getByTestId('confirmModalConfirmButton')); + fireEvent.click(await screen.findByTestId('confirmModalConfirmButton')); expect(casesFormProps.onCancel).toHaveBeenCalled(); expect(sessionStorage.getItem(draftStorageKey)).toBe(null); }); - it('should clear session storage key on submit', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on submit', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const submitBtn = result.getByTestId('create-case-submit'); + const submitBtn = await screen.findByTestId('create-case-submit'); fireEvent.click(submitBtn); @@ -304,4 +224,115 @@ describe('CreateCaseForm', () => { }); }); }); + + describe('templates', () => { + beforeEach(() => { + useGetAllCaseConfigurationsMock.mockReturnValue({ + ...useGetAllCaseConfigurationsResponse, + data: [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + required: false, + label: 'My test label 1', + }, + ], + templates: templatesConfigurationMock, + }, + ], + }); + }); + + it('should populate the cases fields correctly when selecting a case template', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const selectedTemplate = templatesConfigurationMock[4]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(selectedTemplate.caseFields?.title); + expect(description).toHaveValue(selectedTemplate.caseFields?.description); + expect(tags).toHaveTextContent(selectedTemplate.caseFields?.tags?.[0]!); + expect(category).toHaveValue(selectedTemplate.caseFields?.category); + expect(severity).toHaveTextContent('High'); + expect(customField).toHaveValue('this is a text field value'); + expect(await screen.findByText('Damaged Raccoon')).toBeInTheDocument(); + + expect(await screen.findByText('Jira')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('changes templates correctly', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const firstTemplate = templatesConfigurationMock[4]; + const secondTemplate = templatesConfigurationMock[2]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + firstTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const assignees = within(await screen.findByTestId('caseAssignees')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(firstTemplate.caseFields?.title); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + secondTemplate.name + ); + + expect(title).toHaveValue(secondTemplate.caseFields?.title); + expect(description).not.toHaveValue(); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[0]!); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[1]!); + expect(category).not.toHaveValue(); + expect(severity).toHaveTextContent('Medium'); + expect(customField).not.toHaveValue(); + expect(assignees).not.toHaveValue(); + + expect(screen.queryByText('Damaged Raccoon')).not.toBeInTheDocument(); + expect(screen.queryByText('Jira')).not.toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields-jira')).not.toBeInTheDocument(); + + expect(await screen.findByText('No connector selected')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 4c95b6e11a11a..db6df19308e51 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -5,30 +5,13 @@ * 2.0. */ -import React, { useMemo } from 'react'; -import type { EuiThemeComputed } from '@elastic/eui'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiSteps, - useEuiTheme, - logicalCSS, -} from '@elastic/eui'; -import { css } from '@emotion/react'; - +import React, { useCallback, useState, useMemo } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; - -import type { ActionConnector } from '../../../common/types/domain'; import type { CasePostRequest } from '../../../common/types/api'; -import { Title } from './title'; -import { Description, fieldName as descriptionFieldName } from './description'; -import { Tags } from './tags'; -import { Connector } from './connector'; +import { fieldName as descriptionFieldName } from '../case_form_fields/description'; import * as i18n from './translations'; -import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { CaseUI } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import type { CasesTimelineIntegration } from '../timeline_context'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { InsertTimeline } from '../insert_timeline'; @@ -37,33 +20,19 @@ import type { UseCreateAttachments } from '../../containers/use_create_attachmen import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { SubmitCaseButton } from './submit_button'; import { FormContext } from './form_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; -import { Severity } from './severity'; -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'; - -const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => - big - ? css` - ${logicalCSS('margin-top', euiTheme.size.xl)}; - ` - : css` - ${logicalCSS('margin-top', euiTheme.size.base)}; - `; +import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { getConfigurationByOwner } from '../../containers/configure/utils'; +import { CreateCaseOwnerSelector } from './owner_selector'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { getInitialCaseValue, getOwnerDefaultValue } from './utils'; -export interface CreateCaseFormFieldsProps { - connectors: ActionConnector[]; - isLoadingConnectors: boolean; - withSteps: boolean; - draftStorageKey: string; -} export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsProps>, 'withSteps'> { onCancel: () => void; onSuccess: (theCase: CaseUI) => void; @@ -76,130 +45,70 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr initialValue?: Pick<CasePostRequest, 'title' | 'description'>; } -const empty: ActionConnector[] = []; -export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( - ({ connectors, isLoadingConnectors, withSteps, draftStorageKey }) => { +type FormFieldsWithFormContextProps = Pick< + CreateCaseFormFieldsProps, + 'withSteps' | 'draftStorageKey' +> & { + isLoadingCaseConfiguration: boolean; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; + onSelectedOwner: (owner: string) => void; +}; + +export const FormFieldsWithFormContext: React.FC<FormFieldsWithFormContextProps> = React.memo( + ({ + currentConfiguration, + isLoadingCaseConfiguration, + withSteps, + draftStorageKey, + selectedOwner, + onSelectedOwner, + }) => { const { owner } = useCasesContext(); - const { isSubmitting } = useFormContext(); - const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); - const { euiTheme } = useEuiTheme(); const availableOwners = useAvailableCasesOwners(); - const canShowCaseSolutionSelection = !owner.length && availableOwners.length > 1; - - const firstStep = useMemo( - () => ({ - title: i18n.STEP_ONE_TITLE, - children: ( - <> - <Title isLoading={isSubmitting} /> - {caseAssignmentAuthorized ? ( - <div css={containerCss(euiTheme)}> - <Assignees isLoading={isSubmitting} /> - </div> - ) : null} - <div css={containerCss(euiTheme)}> - <Tags isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Category isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Severity isLoading={isSubmitting} /> - </div> - {canShowCaseSolutionSelection && ( - <div css={containerCss(euiTheme, true)}> - <CreateCaseOwnerSelector - availableOwners={availableOwners} - isLoading={isSubmitting} - /> - </div> - )} - <div css={containerCss(euiTheme, true)}> - <Description isLoading={isSubmitting} draftStorageKey={draftStorageKey} /> - </div> - <div css={containerCss(euiTheme)}> - <CustomFields isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)} /> - </> - ), - }), - [ - isSubmitting, - euiTheme, - caseAssignmentAuthorized, - canShowCaseSolutionSelection, - availableOwners, - draftStorageKey, - ] - ); - - const secondStep = useMemo( - () => ({ - title: i18n.STEP_TWO_TITLE, - children: ( - <div> - <SyncAlertsToggle isLoading={isSubmitting} /> - </div> - ), - }), - [isSubmitting] - ); - - const thirdStep = useMemo( - () => ({ - title: i18n.STEP_THREE_TITLE, - children: ( - <div> - <Connector - connectors={connectors} - isLoadingConnectors={isLoadingConnectors} - isLoading={isSubmitting} - /> - </div> - ), - }), - [connectors, isLoadingConnectors, isSubmitting] - ); - - const allSteps = useMemo( - () => [firstStep, ...(isSyncAlertsEnabled ? [secondStep] : []), thirdStep], - [isSyncAlertsEnabled, firstStep, secondStep, thirdStep] + const shouldShowOwnerSelector = Boolean(!owner.length && availableOwners.length > 1); + const { reset } = useFormContext(); + + const { data: connectors = [], isLoading: isLoadingConnectors } = + useGetSupportedActionConnectors(); + + const onOwnerChange = useCallback( + (newOwner: string) => { + onSelectedOwner(newOwner); + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ + owner: newOwner, + connector: currentConfiguration.connector, + }), + }); + }, + [currentConfiguration.connector, onSelectedOwner, reset] ); return ( <> - {isSubmitting && ( - <EuiLoadingSpinner - css={css` - position: absolute; - top: 50%; - left: 50%; - z-index: 99; - `} - data-test-subj="create-case-loading-spinner" - size="xl" - /> - )} - {withSteps ? ( - <EuiSteps - headingElement="h2" - steps={allSteps} - data-test-subj={'case-creation-form-steps'} + {shouldShowOwnerSelector && ( + <CreateCaseOwnerSelector + selectedOwner={selectedOwner} + availableOwners={availableOwners} + isLoading={isLoadingCaseConfiguration} + onOwnerChange={onOwnerChange} /> - ) : ( - <> - {firstStep.children} - {isSyncAlertsEnabled && secondStep.children} - {thirdStep.children} - </> )} + <CreateCaseFormFields + connectors={connectors} + isLoading={isLoadingConnectors || isLoadingCaseConfiguration} + withSteps={withSteps} + draftStorageKey={draftStorageKey} + configuration={currentConfiguration} + /> </> ); } ); -CreateCaseFormFields.displayName = 'CreateCaseFormFields'; +FormFieldsWithFormContext.displayName = 'FormFieldsWithFormContext'; export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( ({ @@ -212,6 +121,13 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( initialValue, }) => { const { owner } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(); + const defaultOwnerValue = owner[0] ?? getOwnerDefaultValue(availableOwners); + const [selectedOwner, onSelectedOwner] = useState<string>(defaultOwnerValue); + + const { data: configurations, isLoading: isLoadingCaseConfiguration } = + useGetAllCaseConfigurations(); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: owner[0], caseId: 'createCase', @@ -233,6 +149,15 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( return onSuccess(theCase); }; + const currentConfiguration = useMemo( + () => + getConfigurationByOwner({ + configurations, + owner: selectedOwner, + }), + [configurations, selectedOwner] + ); + return ( <CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}> <FormContext @@ -240,14 +165,18 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( onSuccess={handleOnSuccess} attachments={attachments} initialValue={initialValue} + currentConfiguration={currentConfiguration} + selectedOwner={selectedOwner} > - <CreateCaseFormFields - connectors={empty} - isLoadingConnectors={false} + <FormFieldsWithFormContext withSteps={withSteps} draftStorageKey={draftStorageKey} + selectedOwner={selectedOwner} + onSelectedOwner={onSelectedOwner} + isLoadingCaseConfiguration={isLoadingCaseConfiguration} + currentConfiguration={currentConfiguration} /> - <div> + <EuiFormRow fullWidth> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" @@ -275,7 +204,7 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( <SubmitCaseButton /> </EuiFlexItem> </EuiFlexGroup> - </div> + </EuiFormRow> <InsertTimeline fieldName={descriptionFieldName} /> </FormContext> </CasesTimelineIntegrationProvider> diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 4c8991f0cb590..5417807edf168 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -16,7 +16,6 @@ import { createAppMockRenderer } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; @@ -39,8 +38,6 @@ import { useGetChoicesResponse, } from './mock'; import { FormContext } from './form_context'; -import type { CreateCaseFormFieldsProps } from './form'; -import { CreateCaseFormFields } from './form'; import { SubmitCaseButton } from './submit_button'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import userEvent from '@testing-library/user-event'; @@ -60,13 +57,15 @@ import { CustomFieldTypes, } from '../../../common/types/domain'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/use_post_case'); jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('../../containers/configure/use_get_all_case_configurations'); jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); @@ -81,7 +80,6 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../app/use_available_owners'); const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const usePostCaseMock = usePostCase as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; @@ -106,8 +104,11 @@ const defaultPostCase = { mutateAsync: postCase, }; +const currentConfiguration = useGetAllCaseConfigurationsResponse.data[0]; + const defaultCreateCaseForm: CreateCaseFormFieldsProps = { - isLoadingConnectors: false, + configuration: currentConfiguration, + isLoading: false, connectors: [], withSteps: true, draftStorageKey: 'cases.kibana.createCase.description.markdownEditor', @@ -205,7 +206,6 @@ describe('Create case', () => { useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); @@ -244,7 +244,11 @@ describe('Create case', () => { describe('Step 1 - Case Fields', () => { it('renders correctly', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -269,7 +273,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -294,7 +302,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -328,7 +340,11 @@ describe('Create case', () => { const newCategory = 'First '; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -373,7 +389,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -408,7 +428,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -431,7 +455,11 @@ describe('Create case', () => { it('should select LOW as the default severity', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -446,27 +474,28 @@ describe('Create case', () => { }); it('should submit form with custom fields', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [ - ...customFieldsConfigurationMock, - { - key: 'my_custom_field_key', - type: CustomFieldTypes.TEXT, - label: 'my custom field label', - required: false, - }, - ], - }, - ], - })); + const configurations = [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + ...customFieldsConfigurationMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'my custom field label', + required: false, + }, + ], + }, + ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={configurations[0]} + > + <CreateCaseFormFields {...defaultCreateCaseForm} configuration={configurations[0]} /> <SubmitCaseButton /> </FormContext> ); @@ -477,7 +506,7 @@ describe('Create case', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -512,147 +541,20 @@ describe('Create case', () => { }); }); - it('should change custom fields based on the selected owner', async () => { - appMockRender = createAppMockRenderer({ owner: [] }); - - const securityCustomField = { - key: 'security_custom_field', - type: CustomFieldTypes.TEXT, - label: 'security custom field', - required: false, - }; - const o11yCustomField = { - key: 'o11y_field_key', - type: CustomFieldTypes.TEXT, - label: 'observability custom field', - required: false, - }; - const stackCustomField = { - key: 'stack_field_key', - type: CustomFieldTypes.TEXT, - label: 'stack custom field', - required: false, - }; - - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'securitySolution', - customFields: [securityCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'observability', - customFields: [o11yCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'cases', - customFields: [stackCustomField], - }, - ], - })); - - appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - ); - - await waitForFormToRender(screen); - await fillFormReactTestingLib({ renderer: screen }); - - const createCaseCustomFields = await screen.findByTestId('create-case-custom-fields'); - - // the default selectedOwner is securitySolution - // only the security custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - const caseOwnerSelector = await screen.findByTestId('caseOwnerSelector'); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Observability')); - - // only the o11y custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Stack')); - - // only the stack custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - }); - it('should select the default connector set in the configuration', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -661,8 +563,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -694,32 +604,19 @@ describe('Create case', () => { }); it('should default to none if the default connector does not exist in connectors', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -728,8 +625,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -757,7 +662,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -788,8 +697,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -861,8 +774,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectors} /> <SubmitCaseButton /> </FormContext> ); @@ -914,8 +831,13 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + afterCaseCreated={afterCaseCreated} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -977,7 +899,12 @@ describe('Create case', () => { ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1008,7 +935,12 @@ describe('Create case', () => { const attachments: CaseAttachments = []; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1044,11 +976,13 @@ describe('Create case', () => { appMockRender.render( <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + currentConfiguration={currentConfiguration} onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated} attachments={attachments} > - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -1098,7 +1032,11 @@ describe('Create case', () => { }; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1129,7 +1067,11 @@ describe('Create case', () => { it('should submit assignees', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1168,7 +1110,11 @@ describe('Create case', () => { useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1193,7 +1139,11 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1221,14 +1171,18 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); await waitForFormToRender(screen); - const descriptionInput = within(screen.getByTestId('caseDescription')).getByTestId( + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByTestId( 'euiMarkdownEditorTextArea' ); 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 04a327868418f..54198f8510e5e 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -5,46 +5,22 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import { CaseSeverity } from '../../../common/types/domain'; -import type { FormProps } from './schema'; import { schema } from './schema'; -import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import type { CasesConfigurationUI, CaseUI, CaseUICustomField } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } 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'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -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 { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useApplication } from '../../common/lib/kibana/use_application'; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - severity: CaseSeverity.LOW, - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +import { createFormSerializer, createFormDeserializer, getInitialCaseValue } from './utils'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; interface Props { afterCaseCreated?: ( @@ -55,6 +31,8 @@ interface Props { onSuccess?: (theCase: CaseUI) => void; attachments?: CaseAttachmentsWithoutOwner; initialValue?: Pick<CasePostRequest, 'title' | 'description'>; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; } export const FormContext: React.FC<Props> = ({ @@ -63,111 +41,23 @@ export const FormContext: React.FC<Props> = ({ onSuccess, attachments, initialValue, + currentConfiguration, + selectedOwner, }) => { - const { data: connectors = [], isLoading: isLoadingConnectors } = - useGetSupportedActionConnectors(); - const { data: allConfigurations } = useGetAllCaseConfigurations(); - const { owner } = useCasesContext(); const { appId } = useApplication(); - const { isSyncAlertsEnabled } = useCasesFeatures(); + const { data: connectors = [] } = useGetSupportedActionConnectors(); const { mutateAsync: postCase } = usePostCase(); const { mutateAsync: createAttachments } = useCreateAttachments(); const { mutateAsync: pushCaseToExternalService } = usePostPushToService(); const { startTransaction } = useCreateCaseWithAttachmentsTransaction(); - const availableOwners = useAvailableCasesOwners(); - - const trimUserFormData = (userFormData: CaseUI) => { - let formData = { - ...userFormData, - title: userFormData.title.trim(), - description: userFormData.description.trim(), - }; - - if (userFormData.category) { - formData = { ...formData, category: userFormData.category.trim() }; - } - - if (userFormData.tags) { - formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; - } - - return formData; - }; - - const transformCustomFieldsData = useCallback( - ( - customFields: Record<string, string | boolean>, - selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] - ) => { - const transformedCustomFields: CaseUI['customFields'] = []; - - if (!customFields || !selectedCustomFieldsConfiguration.length) { - return []; - } - - for (const [key, value] of Object.entries(customFields)) { - const configCustomField = selectedCustomFieldsConfiguration.find( - (item) => item.key === key - ); - if (configCustomField) { - transformedCustomFields.push({ - key: configCustomField.key, - type: configCustomField.type, - value: convertCustomFieldValue(value), - } as CaseUICustomField); - } - } - - return transformedCustomFields; - }, - [] - ); const submitCase = useCallback( - async ( - { - connectorId: dataConnectorId, - fields, - syncAlerts = isSyncAlertsEnabled, - ...dataWithoutConnectorId - }, - isValid - ) => { + async (data: CasePostRequest, isValid) => { if (isValid) { - const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId; - const caseConnector = getConnectorById(dataConnectorId, connectors); - const defaultOwner = owner[0] ?? availableOwners[0]; - startTransaction({ appId, attachments }); - const connectorToUpdate = caseConnector - ? normalizeActionConnector(caseConnector, fields) - : getNoneConnector(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const selectedConfiguration = allConfigurations.find( - (element: CasesConfigurationUI) => element.owner === configurationOwner - ); - - const customFieldsConfiguration = selectedConfiguration - ? selectedConfiguration.customFields - : []; - - const transformedCustomFields = transformCustomFieldsData( - customFields, - customFieldsConfiguration ?? [] - ); - - const trimmedData = trimUserFormData(userFormData); - const theCase = await postCase({ - request: { - ...trimmedData, - connector: connectorToUpdate, - settings: { syncAlerts }, - owner: selectedOwner ?? defaultOwner, - customFields: transformedCustomFields, - }, + request: data, }); // add attachments to the case @@ -183,10 +73,10 @@ export const FormContext: React.FC<Props> = ({ await afterCaseCreated(theCase, createAttachments); } - if (theCase?.id && connectorToUpdate.id !== 'none') { + if (theCase?.id && data.connector.id !== 'none') { await pushCaseToExternalService({ caseId: theCase.id, - connector: connectorToUpdate, + connector: data.connector, }); } @@ -196,15 +86,9 @@ export const FormContext: React.FC<Props> = ({ } }, [ - isSyncAlertsEnabled, - connectors, - owner, - availableOwners, startTransaction, appId, attachments, - transformCustomFieldsData, - allConfigurations, postCase, afterCaseCreated, onSuccess, @@ -213,27 +97,34 @@ export const FormContext: React.FC<Props> = ({ ] ); - const { form } = useForm<FormProps>({ - defaultValue: { ...initialCaseValue, ...initialValue }, + const { form } = useForm({ + defaultValue: { + /** + * This is needed to initiate the connector + * with the one set in the configuration + * when creating a case. + */ + ...getInitialCaseValue({ + owner: selectedOwner, + connector: currentConfiguration.connector, + }), + ...initialValue, + }, options: { stripEmptyFields: false }, schema, onSubmit: submitCase, - serializer: getConnectorsFormSerializer, - deserializer: getConnectorsFormDeserializer, + serializer: (data: CaseFormFieldsSchemaProps) => + createFormSerializer( + connectors, + { + ...currentConfiguration, + owner: selectedOwner, + }, + data + ), + deserializer: createFormDeserializer, }); - const childrenWithExtraProp = useMemo( - () => - children != null - ? React.Children.map(children, (child: React.ReactElement) => - React.cloneElement(child, { - connectors, - isLoadingConnectors, - }) - ) - : null, - [children, connectors, isLoadingConnectors] - ); return ( <Form onKeyDown={(e: KeyboardEvent) => { @@ -245,7 +136,7 @@ export const FormContext: React.FC<Props> = ({ }} form={form} > - {childrenWithExtraProp} + {children} </Form> ); }; diff --git a/x-pack/plugins/cases/public/components/create/form_fields.tsx b/x-pack/plugins/cases/public/components/create/form_fields.tsx new file mode 100644 index 0000000000000..26189e33b7f12 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form_fields.tsx @@ -0,0 +1,204 @@ +/* + * 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, useEffect } from 'react'; +import { + EuiLoadingSpinner, + EuiSteps, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +import type { CasePostRequest } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { Connector } from '../case_form_fields/connector'; +import * as i18n from './translations'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { removeEmptyFields } from '../utils'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { TemplateSelector } from './templates'; +import { getInitialCaseValue } from './utils'; +import { CaseFormFields } from '../case_form_fields'; + +export interface CreateCaseFormFieldsProps { + configuration: CasesConfigurationUI; + connectors: ActionConnector[]; + isLoading: boolean; + withSteps: boolean; + draftStorageKey: string; +} + +const transformTemplateCaseFieldsToCaseFormFields = ( + owner: string, + caseTemplateFields: CasesConfigurationUITemplate['caseFields'] +): CasePostRequest => { + const caseFields = removeEmptyFields(caseTemplateFields ?? {}); + return getInitialCaseValue({ owner, ...caseFields }); +}; + +export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( + ({ configuration, connectors, isLoading, withSteps, draftStorageKey }) => { + const { reset, updateFieldValues, isSubmitting, setFieldValue } = useFormContext(); + const { isSyncAlertsEnabled } = useCasesFeatures(); + const configurationOwner = configuration.owner; + + /** + * Changes the selected connector + * when the user selects a solution. + * Each solution has its own configuration + * so the connector has to change. + */ + useEffect(() => { + setFieldValue('connectorId', configuration.connector.id); + }, [configuration.connector.id, setFieldValue]); + + const onTemplateChange = useCallback( + (caseFields: CasesConfigurationUITemplate['caseFields']) => { + const caseFormFields = transformTemplateCaseFieldsToCaseFormFields( + configurationOwner, + caseFields + ); + + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ owner: configurationOwner }), + }); + updateFieldValues(caseFormFields); + }, + [configurationOwner, reset, updateFieldValues] + ); + + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <TemplateSelector + isLoading={isSubmitting || isLoading} + templates={configuration.templates} + onTemplateChange={onTemplateChange} + /> + ), + }), + [configuration.templates, isLoading, isSubmitting, onTemplateChange] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( + <CaseFormFields + configurationCustomFields={configuration.customFields} + isLoading={isSubmitting} + setCustomFieldsOptional={false} + isEditMode={false} + draftStorageKey={draftStorageKey} + /> + ), + }), + [configuration.customFields, draftStorageKey, isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( + () => ({ + title: i18n.STEP_FOUR_TITLE, + children: ( + <Connector + connectors={connectors} + isLoadingConnectors={isLoading} + isLoading={isSubmitting} + key={configuration.id} + /> + ), + }), + [configuration.id, connectors, isLoading, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, isSyncAlertsEnabled, thirdStep, fourthStep] + ); + + return ( + <> + {isSubmitting && ( + <EuiLoadingSpinner + css={css` + position: absolute; + top: 50%; + left: 50%; + z-index: 99; + `} + data-test-subj="create-case-loading-spinner" + size="xl" + /> + )} + {withSteps ? ( + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'case-creation-form-steps'} + /> + ) : ( + <> + <EuiSpacer size="l" /> + <EuiFlexGroup direction="column"> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_ONE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{firstStep.children}</EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_TWO_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{secondStep.children}</EuiFlexItem> + </EuiFlexGroup> + {isSyncAlertsEnabled && ( + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_THREE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{thirdStep.children}</EuiFlexItem> + </EuiFlexGroup> + )} + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_FOUR_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{fourthStep.children}</EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </> + )} + </> + ); + } +); + +CreateCaseFormFields.displayName = 'CreateCaseFormFields'; diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx index 451207b080dfb..c61dd83dea42f 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx @@ -11,13 +11,14 @@ import { waitFor, screen } from '@testing-library/react'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { OBSERVABILITY_OWNER, OWNER_INFO } from '../../../common/constants'; import { CreateCaseOwnerSelector } from './owner_selector'; -import { FormTestComponent } from '../../common/test_utils'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import userEvent from '@testing-library/user-event'; describe('Case Owner Selection', () => { - const onSubmit = jest.fn(); + const onOwnerChange = jest.fn(); + const selectedOwner = SECURITY_SOLUTION_OWNER; + let appMockRender: AppMockRenderer; beforeEach(() => { @@ -25,92 +26,66 @@ describe('Case Owner Selection', () => { appMockRender = createAppMockRenderer(); }); - it('renders', async () => { + it('renders all options', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[SECURITY_SOLUTION_OWNER]} isLoading={false} /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); - }); - it.each([ - [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER], - [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], - ])('disables %s button if user only has %j', async (disabledButton, permission) => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[permission]} isLoading={false} /> - </FormTestComponent> - ); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - expect(await screen.findByLabelText(OWNER_INFO[disabledButton].label)).toBeDisabled(); - expect(await screen.findByLabelText(OWNER_INFO[permission].label)).not.toBeDisabled(); + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveTextContent(OWNER_INFO[SECURITY_SOLUTION_OWNER].label); + expect(options[1]).toHaveTextContent(OWNER_INFO[OBSERVABILITY_OWNER].label); }); - it('defaults to security Solution', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> + it.each([[SECURITY_SOLUTION_OWNER], [OBSERVABILITY_OWNER]])( + 'only displays %s option if available', + async (available) => { + appMockRender.render( <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + availableOwners={[available]} isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={available} /> - </FormTestComponent> - ); - - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + ); - it('defaults to security Solution with empty owners', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[]} isLoading={false} /> - </FormTestComponent> - ); + expect(await screen.findByText(OWNER_INFO[available].label)).toBeInTheDocument(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + expect((await screen.findAllByRole('option')).length).toBe(1); + } + ); it('changes the selection', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} - isLoading={false} - /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); - expect(await screen.findByLabelText('Security')).toBeChecked(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); + expect(await screen.findByText('Security')).toBeInTheDocument(); + expect(screen.queryByText('Observability')).not.toBeInTheDocument(); - userEvent.click(await screen.findByLabelText('Observability')); - - expect(await screen.findByLabelText('Observability')).toBeChecked(); - expect(await screen.findByLabelText('Security')).not.toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); + userEvent.click(await screen.findByText('Observability'), undefined, { + skipPointerEventsCheck: true, + }); await waitFor(() => { // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'observability' }, true); + expect(onOwnerChange).toBeCalledWith('observability'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.tsx index 00dd4a03f2664..314bbaefc95c8 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.tsx @@ -5,113 +5,72 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiKeyPadMenu, - EuiKeyPadMenuItem, - useGeneratedHtmlId, -} from '@elastic/eui'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { - getFieldValidityAndErrorMessage, - UseField, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import { OWNER_INFO } from '../../../common/constants'; import * as i18n from './translations'; -interface OwnerSelectorProps { - field: FieldHook<string>; - isLoading: boolean; - availableOwners: string[]; -} - interface Props { + selectedOwner: string; availableOwners: string[]; isLoading: boolean; + onOwnerChange: (owner: string) => void; } -const DEFAULT_SELECTABLE_OWNERS = Object.keys(OWNER_INFO) as Array<keyof typeof OWNER_INFO>; - -const FIELD_NAME = 'selectedOwner'; - -const FullWidthKeyPadMenu = euiStyled(EuiKeyPadMenu)` - width: 100%; -`; - -const FullWidthKeyPadItem = euiStyled(EuiKeyPadMenuItem)` - - width: 100%; -`; - -const OwnerSelector = ({ +const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, - field, - isLoading = false, -}: OwnerSelectorProps): JSX.Element => { - const { errorMessage, isInvalid } = getFieldValidityAndErrorMessage(field); - const radioGroupName = useGeneratedHtmlId({ prefix: 'caseOwnerRadioGroup' }); - - const onChange = useCallback((val: string) => field.setValue(val), [field]); + isLoading, + onOwnerChange, + selectedOwner, +}) => { + const onChange = (owner: string) => { + onOwnerChange(owner); + }; + + const options = Object.entries(OWNER_INFO) + .filter(([owner]) => availableOwners.includes(owner)) + .map(([owner, definition]) => ({ + value: owner, + inputDisplay: ( + <EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiIcon + type={definition.iconType} + size="m" + title={definition.label} + className="eui-alignMiddle" + /> + </EuiFlexItem> + <EuiFlexItem> + <small>{definition.label}</small> + </EuiFlexItem> + </EuiFlexGroup> + ), + 'data-test-subj': `${definition.id}OwnerOption`, + })); return ( <EuiFormRow + display="columnCompressed" + label={i18n.SOLUTION_SELECTOR_LABEL} data-test-subj="caseOwnerSelector" fullWidth - isInvalid={isInvalid} - error={errorMessage} - helpText={field.helpText} - label={field.label} - labelAppend={field.labelAppend} > - <FullWidthKeyPadMenu checkable={{ ariaLegend: i18n.ARIA_KEYPAD_LEGEND }}> - <EuiFlexGroup> - {DEFAULT_SELECTABLE_OWNERS.map((owner) => ( - <EuiFlexItem key={owner}> - <FullWidthKeyPadItem - data-test-subj={`${owner}RadioButton`} - onChange={onChange} - checkable="single" - name={radioGroupName} - id={owner} - label={OWNER_INFO[owner].label} - isSelected={field.value === owner} - isDisabled={isLoading || !availableOwners.includes(owner)} - > - <EuiIcon type={OWNER_INFO[owner].iconType} size="xl" /> - </FullWidthKeyPadItem> - </EuiFlexItem> - ))} - </EuiFlexGroup> - </FullWidthKeyPadMenu> + <EuiSuperSelect + data-test-subj="caseOwnerSuperSelect" + options={options} + isLoading={isLoading} + fullWidth + valueOfSelected={selectedOwner} + onChange={(owner) => onChange(owner)} + compressed + /> </EuiFormRow> ); }; -OwnerSelector.displayName = 'OwnerSelector'; - -const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, isLoading }) => { - const defaultValue = availableOwners.includes(SECURITY_SOLUTION_OWNER) - ? SECURITY_SOLUTION_OWNER - : availableOwners[0] ?? SECURITY_SOLUTION_OWNER; - - return ( - <UseField - path={FIELD_NAME} - config={{ defaultValue }} - component={OwnerSelector} - componentProps={{ availableOwners, isLoading }} - /> - ); -}; - CaseOwnerSelector.displayName = 'CaseOwnerSelectionComponent'; export const CreateCaseOwnerSelector = memo(CaseOwnerSelector); diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 9d07efbf36111..2f92857930d98 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,140 +5,34 @@ * 2.0. */ -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { FIELD_TYPES, VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import type { ConnectorTypeFields } from '../../../common/types/domain'; -import type { CasePostRequest } from '../../../common/types/api'; -import { - MAX_TITLE_LENGTH, - MAX_DESCRIPTION_LENGTH, - MAX_LENGTH_PER_TAG, - MAX_TAGS_PER_CASE, -} from '../../../common/constants'; import * as i18n from './translations'; -import { OptionalFieldLabel } from './optional_field_label'; -import { SEVERITY_TITLE } from '../severity/translations'; -const { emptyField, maxLengthField } = fieldValidators; +const { emptyField } = fieldValidators; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; -const isInvalidTag = (value: string) => value.trim() === ''; +const caseFormFieldsSchemaTyped = caseFormFieldsSchema as Record<string, FieldConfig<string>>; -const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; - -export const schemaTags = { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isInvalidTag(value)) || - (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) - ) { - return { - message: i18n.TAGS_EMPTY_ERROR, - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isTagCharactersInLimit(value)) || - (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) - ) { - return { - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string[] }) => { - if (Array.isArray(value) && value.length > MAX_TAGS_PER_CASE) { - return { - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - }; - } - }, - }, - ], -}; - -export type FormProps = Omit< - CasePostRequest, - 'connector' | 'settings' | 'owner' | 'customFields' -> & { - connectorId: string; - fields: ConnectorTypeFields['fields']; - syncAlerts: boolean; - selectedOwner?: string | null; - customFields: Record<string, string | boolean>; -}; - -export const schema: FormSchema<FormProps> = { +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { + ...caseFormFieldsSchema, title: { - type: FIELD_TYPES.TEXT, - label: i18n.NAME, + ...caseFormFieldsSchemaTyped.title, validations: [ { validator: emptyField(i18n.TITLE_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_TITLE_LENGTH, - message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), - }), - }, + ...(caseFormFieldsSchemaTyped.title.validations ?? []), ], }, description: { - label: i18n.DESCRIPTION, + ...caseFormFieldsSchemaTyped.description, validations: [ { validator: emptyField(i18n.DESCRIPTION_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_DESCRIPTION_LENGTH, - message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), - }), - }, - ], - }, - selectedOwner: { - label: i18n.SOLUTION, - type: FIELD_TYPES.RADIO_GROUP, - validations: [ - { - validator: emptyField(i18n.SOLUTION_REQUIRED), - }, + ...(caseFormFieldsSchemaTyped.description.validations ?? []), ], }, - tags: schemaTags, - severity: { - label: SEVERITY_TITLE, - }, - connectorId: { - type: FIELD_TYPES.SUPER_SELECT, - label: i18n.CONNECTORS, - defaultValue: 'none', - }, - fields: { - defaultValue: null, - }, - syncAlerts: { - helpText: i18n.SYNC_ALERTS_HELP, - type: FIELD_TYPES.TOGGLE, - defaultValue: true, - }, - assignees: {}, - category: {}, }; diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx deleted file mode 100644 index 9ac7658547725..0000000000000 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx +++ /dev/null @@ -1,82 +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 type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -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 { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { FormProps } from './schema'; -import { schema } from './schema'; - -describe('SyncAlertsToggle', () => { - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { syncAlerts: true }, - schema: { - syncAlerts: schema.syncAlerts, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; - }; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('it renders', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - }); - - it('it toggles the switch', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); - - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ syncAlerts: false }); - }); - }); - - it('it shows the correct labels', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text()).toBe( - 'On' - ); - - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text() - ).toBe('Off'); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/template.test.tsx b/x-pack/plugins/cases/public/components/create/template.test.tsx new file mode 100644 index 0000000000000..d3b1c59b71254 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/template.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { templatesConfigurationMock } from '../../containers/mock'; +import { TemplateSelector } from './templates'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onTemplateChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + expect(await screen.findByText('Template name')).toBeInTheDocument(); + expect(await screen.findByTestId('create-case-template-select')).toBeInTheDocument(); + }); + + it('selects a template correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + await waitFor(() => { + expect(onTemplateChange).toHaveBeenCalledWith(selectedTemplate.caseFields); + }); + }); + + it('shows the selected option correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + expect( + (await screen.findByRole<HTMLOptionElement>('option', { name: selectedTemplate.name })) + .selected + ).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/templates.tsx b/x-pack/plugins/cases/public/components/create/templates.tsx new file mode 100644 index 0000000000000..612a7d8a24a70 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/templates.tsx @@ -0,0 +1,69 @@ +/* + * 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 { EuiSelectOption } from '@elastic/eui'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { TEMPLATE_HELP_TEXT, TEMPLATE_LABEL } from './translations'; + +interface Props { + isLoading: boolean; + templates: CasesConfigurationUI['templates']; + onTemplateChange: (caseFields: CasesConfigurationUITemplate['caseFields']) => void; +} + +export const TemplateSelectorComponent: React.FC<Props> = ({ + isLoading, + templates, + onTemplateChange, +}) => { + const [selectedTemplate, onSelectTemplate] = useState<string>(); + + const options: EuiSelectOption[] = templates.map((template) => ({ + text: template.name, + value: template.key, + })); + + const onChange: React.ChangeEventHandler<HTMLSelectElement> = useCallback( + (e) => { + const selectedTemplated = templates.find((template) => template.key === e.target.value); + + if (selectedTemplated) { + onSelectTemplate(selectedTemplated.key); + onTemplateChange(selectedTemplated.caseFields); + } + }, + [onTemplateChange, templates] + ); + + return ( + <EuiFormRow + id="createCaseTemplate" + fullWidth + label={TEMPLATE_LABEL} + labelAppend={OptionalFieldLabel} + helpText={TEMPLATE_HELP_TEXT} + > + <EuiSelect + onChange={onChange} + options={options} + disabled={isLoading} + isLoading={isLoading} + data-test-subj="create-case-template-select" + fullWidth + hasNoInitialSelection + value={selectedTemplate} + /> + </EuiFormRow> + ); +}; + +TemplateSelectorComponent.displayName = 'TemplateSelector'; + +export const TemplateSelector = React.memo(TemplateSelectorComponent); diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 473cc40a6a3f8..aef9c7c525acd 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -11,14 +11,18 @@ export * from '../../common/translations'; export * from '../user_profiles/translations'; export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { - defaultMessage: 'Case fields', + defaultMessage: 'Select template', }); export const STEP_TWO_TITLE = i18n.translate('xpack.cases.create.stepTwoTitle', { - defaultMessage: 'Case settings', + defaultMessage: 'Case fields', }); export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitle', { + defaultMessage: 'Case settings', +}); + +export const STEP_FOUR_TITLE = i18n.translate('xpack.cases.create.stepFourTitle', { defaultMessage: 'External Connector Fields', }); @@ -45,3 +49,15 @@ export const CANCEL_MODAL_BUTTON = i18n.translate('xpack.cases.create.cancelModa export const CONFIRM_MODAL_BUTTON = i18n.translate('xpack.cases.create.confirmModalButton', { defaultMessage: 'Exit without saving', }); + +export const TEMPLATE_LABEL = i18n.translate('xpack.cases.create.templateLabel', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_HELP_TEXT = i18n.translate('xpack.cases.create.templateHelpText', { + defaultMessage: 'Selecting a template will pre-fill certain case fields below', +}); + +export const SOLUTION_SELECTOR_LABEL = i18n.translate('xpack.cases.create.solutionSelectorLabel', { + defaultMessage: 'Create case under:', +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.test.ts b/x-pack/plugins/cases/public/components/create/utils.test.ts new file mode 100644 index 0000000000000..6b8c9c9017fc4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.test.ts @@ -0,0 +1,383 @@ +/* + * 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 { + getInitialCaseValue, + trimUserFormData, + getOwnerDefaultValue, + createFormDeserializer, + createFormSerializer, +} from './utils'; +import { ConnectorTypes, CaseSeverity, CustomFieldTypes } from '../../../common/types/domain'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; + +describe('utils', () => { + describe('getInitialCaseValue', () => { + it('returns expected initial values', () => { + const params = { + owner: 'foobar', + connector: { + id: 'foo', + name: 'bar', + type: ConnectorTypes.jira as const, + fields: null, + }, + }; + expect(getInitialCaseValue(params)).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + ...params, + }); + }); + + it('returns none connector when none is specified', () => { + expect(getInitialCaseValue({ owner: 'foobar' })).toEqual({ + assignees: [], + category: undefined, + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: '', + owner: 'foobar', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + }); + }); + + it('returns extra fields', () => { + const extraFields = { + owner: 'foobar', + title: 'my title', + assignees: [ + { + uid: 'uid', + }, + ], + tags: ['my tag'], + category: 'categorty', + severity: CaseSeverity.HIGH as const, + description: 'Cool description', + settings: { syncAlerts: false }, + customFields: [{ key: 'key', type: CustomFieldTypes.TEXT as const, value: 'text' }], + }; + + expect(getInitialCaseValue(extraFields)).toEqual({ + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + ...extraFields, + }); + }); + }); + + describe('trimUserFormData', () => { + it('trims applicable fields in the user form data', () => { + const userFormData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + category: userFormData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + + it('ignores category and tags if they are missing', () => { + const userFormData = { + title: ' title ', + description: ' description ', + tags: [], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + tags: [], + }); + }); + }); + + describe('getOwnerDefaultValue', () => { + it('returns the general cases owner if it exists', () => { + expect(getOwnerDefaultValue(['foobar', GENERAL_CASES_OWNER])).toEqual(GENERAL_CASES_OWNER); + }); + + it('returns the first available owner if the general cases owner is not available', () => { + expect(getOwnerDefaultValue(['foo', 'bar'])).toEqual('foo'); + }); + + it('returns the general cases owner if no owner is available', () => { + expect(getOwnerDefaultValue([])).toEqual(GENERAL_CASES_OWNER); + }); + }); + + describe('createFormSerializer', () => { + const dataToSerialize = { + title: 'title', + description: 'description', + tags: [], + connectorId: '', + fields: { incidentTypes: null, severityCode: null }, + customFields: {}, + syncAlerts: false, + }; + const serializedFormData = { + title: 'title', + description: 'description', + customFields: [], + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + owner: casesConfigurationsMock.owner, + }; + + it('returns empty values with owner and connector from configuration when data is empty', () => { + // @ts-ignore: this is what we are trying to test + expect(createFormSerializer([], casesConfigurationsMock, {})).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + connector: casesConfigurationsMock.connector, + owner: casesConfigurationsMock.owner, + }); + }); + + it('normalizes action connectors', () => { + expect( + createFormSerializer( + [ + { + id: 'test', + actionTypeId: '.test', + name: 'My connector', + isDeprecated: false, + isPreconfigured: false, + config: { foo: 'bar' }, + isMissingSecrets: false, + isSystemAction: false, + }, + ], + casesConfigurationsMock, + { + ...dataToSerialize, + connectorId: 'test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + } + ) + ).toEqual({ + ...serializedFormData, + connector: { + id: 'test', + name: 'My connector', + type: '.test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + }, + }); + }); + + it('transforms custom fields', () => { + expect( + createFormSerializer([], casesConfigurationsMock, { + ...dataToSerialize, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }) + ).toEqual({ + ...serializedFormData, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ], + }); + }); + + it('trims form data', () => { + const untrimmedData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect( + // @ts-ignore: expected incomplete form data + createFormSerializer([], casesConfigurationsMock, { ...dataToSerialize, ...untrimmedData }) + ).toEqual({ + ...serializedFormData, + title: untrimmedData.title.trim(), + description: untrimmedData.description.trim(), + category: untrimmedData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + }); + + describe('createFormDeserializer', () => { + it('deserializes data as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: {}, + }); + }); + + it('deserializes customFields as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [ + { + key: 'test_key_1', + type: CustomFieldTypes.TEXT, + value: 'first value', + }, + { + key: 'test_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'test_key_3', + type: CustomFieldTypes.TEXT, + value: 'second value', + }, + ], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.ts b/x-pack/plugins/cases/public/components/create/utils.ts new file mode 100644 index 0000000000000..daeac67066c9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.ts @@ -0,0 +1,118 @@ +/* + * 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 type { CasePostRequest } from '../../../common'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { CaseSeverity } from '../../../common/types/domain'; +import type { CasesConfigurationUI } from '../../containers/types'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormSerializer, +} from '../utils'; + +type GetInitialCaseValueArgs = Partial<Omit<CasePostRequest, 'owner'>> & + Pick<CasePostRequest, 'owner'>; + +export const getInitialCaseValue = ({ + owner, + connector, + ...restFields +}: GetInitialCaseValueArgs): CasePostRequest => ({ + title: '', + assignees: [], + tags: [], + category: undefined, + severity: CaseSeverity.LOW as const, + description: '', + settings: { syncAlerts: true }, + customFields: [], + ...restFields, + connector: connector ?? getNoneConnector(), + owner, +}); + +export const trimUserFormData = ( + userFormData: Omit< + CaseFormFieldsSchemaProps, + 'connectorId' | 'fields' | 'syncAlerts' | 'customFields' + > +) => { + let formData = { + ...userFormData, + title: userFormData.title.trim(), + description: userFormData.description.trim(), + }; + + if (userFormData.category) { + formData = { ...formData, category: userFormData.category.trim() }; + } + + if (userFormData.tags) { + formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; + } + + return formData; +}; + +export const createFormDeserializer = (data: CasePostRequest): CaseFormFieldsSchemaProps => { + const { connector, settings, customFields, ...restData } = data; + + return { + ...restData, + connectorId: connector.id, + fields: connector.fields, + syncAlerts: settings.syncAlerts, + customFields: customFieldsFormDeserializer(customFields) ?? {}, + }; +}; + +export const createFormSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: CaseFormFieldsSchemaProps +): CasePostRequest => { + if (data == null || isEmpty(data)) { + return getInitialCaseValue({ + owner: currentConfiguration.owner, + connector: currentConfiguration.connector, + }); + } + + const { connectorId: dataConnectorId, fields, syncAlerts, customFields, ...restData } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields }); + const caseConnector = getConnectorById(dataConnectorId, connectors); + const connectorToUpdate = caseConnector + ? normalizeActionConnector(caseConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedCustomFields = customFieldsFormSerializer( + customFields, + currentConfiguration.customFields + ); + + const trimmedData = trimUserFormData(restData); + + return { + ...trimmedData, + connector: connectorToUpdate, + settings: { syncAlerts: syncAlerts ?? false }, + owner: currentConfiguration.owner, + customFields: transformedCustomFields, + }; +}; + +export const getOwnerDefaultValue = (availableOwners: string[]) => + availableOwners.includes(GENERAL_CASES_OWNER) + ? GENERAL_CASES_OWNER + : availableOwners[0] ?? GENERAL_CASES_OWNER; 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 index 002d3e65b4e61..fab80347300d0 100644 --- 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 @@ -99,7 +99,7 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); }); it('calls onDeleteCustomField when confirm', async () => { @@ -113,12 +113,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Delete')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).toHaveBeenCalledWith( customFieldsConfigurationMock[0].key ); @@ -136,12 +136,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).not.toHaveBeenCalledWith(); }); }); 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 index cfccb53e48db3..f8475a90b94ad 100644 --- 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 @@ -20,7 +20,7 @@ import * as i18n from '../translations'; import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; import { builderMap } from '../builder'; -import { DeleteConfirmationModal } from '../delete_confirmation_modal'; +import { DeleteConfirmationModal } from '../../configure_cases/delete_confirmation_modal'; export interface Props { customFields: CustomFieldsConfiguration; @@ -111,7 +111,8 @@ const CustomFieldsListComponent: React.FC<Props> = (props) => { </EuiFlexItem> {showModal && selectedItem ? ( <DeleteConfirmationModal - label={selectedItem.label} + title={i18n.DELETE_FIELD_TITLE(selectedItem.label)} + message={i18n.DELETE_FIELD_DESCRIPTION} onCancel={onCancel} onConfirm={onConfirm} /> 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 deleted file mode 100644 index 508f124a7746c..0000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx +++ /dev/null @@ -1,270 +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 { fireEvent, 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, - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, -} from '../../../common/constants'; -import { CustomFieldTypes } from '../../../common/types/domain'; - -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(<CustomFieldFlyout {...props} />); - - expect(await screen.findByTestId('custom-field-flyout-header')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-cancel')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-save')).toBeInTheDocument(); - }); - - it('shows error if field label is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); - - userEvent.type(await screen.findByTestId('custom-field-label-input'), message); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR(i18n.FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) - ) - ).toBeInTheDocument(); - }); - - it('does not call onSaveField when error', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - expect( - await screen.findByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL.toLocaleLowerCase())) - ).toBeInTheDocument(); - - expect(props.onSaveField).not.toBeCalled(); - }); - - it('calls onCloseFlyout on cancel', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-cancel')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - it('calls onCloseFlyout on close', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - describe('Text custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[0] }} /> - ); - - 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-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[0].defaultValue - ); - }); - - it('shows an error if default value is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) - ); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR( - i18n.DEFAULT_VALUE.toLowerCase(), - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH - ) - ) - ).toBeInTheDocument(); - }); - }); - - describe('Toggle custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('calls onSaveField with the correct default value when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('toggle-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[1] }} /> - ); - - expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[1].label - ); - expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); - expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( - 'aria-checked', - 'true' - ); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx deleted file mode 100644 index 0be2c4ea43bcb..0000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx +++ /dev/null @@ -1,105 +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, { 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<CustomFieldFlyoutProps> = ({ - onCloseFlyout, - onSaveField, - isLoading, - disabled, - customField, -}) => { - const dataTestSubj = 'custom-field-flyout'; - - const [formState, setFormState] = useState<CustomFieldFormState>({ - 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 ( - <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> - <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}-header`}> - <EuiTitle size="s"> - <h3 id="flyoutTitle">{i18n.ADD_CUSTOM_FIELD}</h3> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <CustomFieldsForm initialValue={customField} onChange={setFormState} /> - </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={`${dataTestSubj}-footer`}> - <EuiFlexGroup justifyContent="flexStart"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - onClick={onCloseFlyout} - data-test-subj={`${dataTestSubj}-cancel`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButton - fill - onClick={handleSaveField} - data-test-subj={`${dataTestSubj}-save`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.SAVE_FIELD} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexGroup> - </EuiFlyoutFooter> - </EuiFlyout> - ); -}; - -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 index ef2cbac458678..89fdca73fefbf 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx @@ -10,13 +10,13 @@ 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 type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; import userEvent from '@testing-library/user-event'; import { customFieldsConfigurationMock } from '../../containers/mock'; +import type { FormState } from '../configure_cases/flyout'; describe('CustomFieldsForm ', () => { let appMockRender: AppMockRenderer; @@ -68,9 +68,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -96,9 +96,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is not filled', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -122,9 +122,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is an empty string', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -149,9 +149,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if the initial default value is null', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); const initialValue = { required: true, @@ -190,9 +190,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is not selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -215,9 +215,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: text" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[0]} /> @@ -247,9 +247,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: toggle" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={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 index 230b947db854d..2a2c675aac31d 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -5,7 +5,6 @@ * 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'; @@ -15,14 +14,10 @@ import { FormFields } from './form_fields'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import { customFieldSerializer } from './utils'; - -export interface CustomFieldFormState { - isValid: boolean | undefined; - submit: FormHook<CustomFieldConfiguration>['submit']; -} +import type { FormState } from '../configure_cases/flyout'; interface Props { - onChange: (state: CustomFieldFormState) => void; + onChange: (state: FormState<CustomFieldConfiguration>) => void; initialValue: CustomFieldConfiguration | null; } 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 index 9db8541993057..0b62466fa6858 100644 --- 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 @@ -53,6 +53,22 @@ describe('Create ', () => { ); }); + it('does not render default value when setDefaultValue is false', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + setDefaultValue={false} + /> + </FormTestComponent> + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveValue(''); + }); + it('renders loading state correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> 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 index aaab2043fb332..f735a4034f024 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -11,16 +11,19 @@ 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'; +import { OptionalFieldLabel } from '../../optional_field_label'; const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, + setAsOptional, + setDefaultValue = true, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ - required, + required: setAsOptional ? false : required, label, - ...(defaultValue && { defaultValue: String(defaultValue) }), + ...(defaultValue && setDefaultValue && { defaultValue: String(defaultValue) }), }); return ( @@ -30,6 +33,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ component={TextField} label={label} componentProps={{ + labelAppend: setAsOptional ? OptionalFieldLabel : null, euiFieldProps: { 'data-test-subj': `${key}-text-create-custom-field`, fullWidth: true, 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 index 9672b3c8bb6be..8eb7c50300840 100644 --- 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 @@ -36,6 +36,20 @@ describe('Create ', () => { expect(await screen.findByRole('switch')).toBeChecked(); // defaultValue true }); + it('does not render default value when setDefaultValue is false', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + setDefaultValue={false} + /> + </FormTestComponent> + ); + + expect(await screen.findByRole('switch')).not.toBeChecked(); + }); + it('updates the value correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> 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 index 2d3f51bc4f678..eb3ad2b114e57 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx @@ -14,6 +14,7 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, + setDefaultValue = true, }) => { const { key, label, defaultValue } = customFieldConfiguration; @@ -21,7 +22,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ <UseField path={`customFields.${key}`} component={ToggleField} - config={{ defaultValue: defaultValue ? defaultValue : false }} + config={{ defaultValue: defaultValue && setDefaultValue ? defaultValue : false }} key={key} label={label} componentProps={{ diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts index 856ff7e9e1c60..a1dcffaec6b97 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -30,6 +30,8 @@ export interface CustomFieldType<T extends CaseUICustomField> { Create: React.FC<{ customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; + setAsOptional?: boolean; + setDefaultValue?: boolean; }>; } 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 index ba629a6ea10a4..5a21319645836 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -5,202 +5,11 @@ * 2.0. */ -import { addOrReplaceCustomField, customFieldSerializer } from './utils'; -import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock'; +import { customFieldSerializer } from './utils'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; -import type { CaseUICustomField } from '../../../common/ui'; describe('utils ', () => { - 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": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - Object { - "key": "my_test_key", - "type": "text", - "value": "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] }, - { ...customFieldsMock[2] }, - { ...customFieldsMock[3] }, - ], - ` - Array [ - Object { - "field": Object { - "value": Array [ - "My text test value 1!!!", - ], - }, - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - ] - ` - ); - }); - - 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 { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "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] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1!!!", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - ] - ` - ); - }); - }); - describe('customFieldSerializer ', () => { it('serializes the data correctly if the default value is a normal string', async () => { const customField = { diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts index bea01a3761bd0..3842b75b5a7ea 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -9,27 +9,6 @@ import { isEmptyString } from '@kbn/es-ui-shared-plugin/static/validators/string import { isString } from 'lodash'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; -export const addOrReplaceCustomField = <T extends { key: string }>( - 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; - }); -}; - export const customFieldSerializer = ( field: CustomFieldConfiguration ): CustomFieldConfiguration => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index c939feda42e40..b1437e2e2a253 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -34,7 +34,7 @@ type MarkdownEditorFormProps = EuiMarkdownEditorProps & { bottomRightContent?: React.ReactNode; caseTitle?: string; caseTags?: string[]; - draftStorageKey: string; + draftStorageKey?: string; disabledUiPlugins?: string[]; initialValue?: string; }; @@ -59,7 +59,7 @@ export const MarkdownEditorForm = React.memo( const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const { hasConflicts } = useMarkdownSessionStorage({ field, - sessionKey: draftStorageKey, + sessionKey: draftStorageKey ?? '', initialValue, }); const { euiTheme } = useEuiTheme(); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx index 7de2e83cf234d..e4ce68ed45237 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx @@ -54,6 +54,17 @@ describe('useMarkdownSessionStorage', () => { }); }); + it('should return hasConflicts as false when sessionKey is empty', async () => { + const { result, waitFor } = renderHook(() => + useMarkdownSessionStorage({ field, sessionKey: '', initialValue }) + ); + + await waitFor(() => { + expect(field.setValue).not.toHaveBeenCalled(); + expect(result.current.hasConflicts).toBe(false); + }); + }); + it('should update the session value with field value when it is first render', async () => { const { waitFor } = renderHook<SessionStorageType, { hasConflicts: boolean }>( (props) => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx index e33fed6729858..0a82d43cc093d 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx @@ -30,7 +30,7 @@ export const useMarkdownSessionStorage = ({ const [sessionValue, setSessionValue] = useSessionStorage(sessionKey, '', true); - if (!isEmpty(sessionValue) && isFirstRender.current) { + if (!isEmpty(sessionValue) && !isEmpty(sessionKey) && isFirstRender.current) { field.setValue(sessionValue); } @@ -45,7 +45,9 @@ export const useMarkdownSessionStorage = ({ useDebounce( () => { - setSessionValue(field.value); + if (!isEmpty(sessionKey)) { + setSessionValue(field.value); + } }, STORAGE_DEBOUNCE_TIME, [field.value] diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx similarity index 89% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.tsx index ea994b2219961..98c101440116a 100644 --- a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx +++ b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx @@ -8,7 +8,7 @@ import { EuiText } from '@elastic/eui'; import React from 'react'; -import * as i18n from '../../../common/translations'; +import * as i18n from '../../common/translations'; export const OptionalFieldLabel = ( <EuiText color="subdued" size="xs" data-test-subj="form-optional-field-label"> diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx new file mode 100644 index 0000000000000..a01aa25132cb5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -0,0 +1,790 @@ +/* + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, +} from '../../../common/constants'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import type { FormState } from '../configure_cases/flyout'; +import { TemplateForm } from './form'; +import type { TemplateFormProps } from './types'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('TemplateForm', () => { + let appMockRenderer: AppMockRenderer; + const defaultProps = { + connectors: connectorsMock, + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, + onChange: jest.fn(), + initialValue: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all default fields', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders all fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: null, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Template 1'); + expect(await screen.findByTestId('template-description-input')).toHaveValue( + 'Sample description' + ); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders case fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: { + title: 'Case with template 1', + description: 'case description', + }, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case with template 1' + ); + expect( + await within(await screen.findByTestId('caseDescription')).findByTestId( + 'euiMarkdownEditorTextArea' + ) + ).toHaveValue('case description'); + }); + + it('renders case fields as optional', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + const title = await screen.findByTestId('caseTitle'); + const tags = await screen.findByTestId('caseTags'); + const category = await screen.findByTestId('caseCategory'); + const description = await screen.findByTestId('caseDescription'); + + expect(await within(title).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(tags).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(category).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(description).findByTestId('form-optional-field-label')).toBeInTheDocument(); + }); + + it('serializes the template field data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'bar'); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'this is a first template', + name: 'Template 1', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the template field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { ...templatesConfigurationMock[0], tags: ['foo', 'bar'] }, + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'This is a first test template', + name: 'First test template', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the case field data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, + onChange: onChangeState, + }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'This is a case description', + settings: { + syncAlerts: true, + }, + tags: ['template-1'], + title: 'Case with Template 1', + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the case field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: templatesConfigurationMock[3], + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Case with sample template 4', + }, + description: 'This is a fourth test template', + name: 'Fourth test template', + tags: ['foo', 'bar'], + }); + }); + }); + + it('serializes the connector fields data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + onChange: onChangeState, + }} + /> + ); + + await screen.findByTestId('caseConnectors'); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the connector fields data correctly with existing connector', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + type: ConnectorTypes.serviceNowITSM, + name: 'my-SN-connector', + fields: null, + }, + }, + }, + connectors: connectorsMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'resilient-2', + name: 'My Resilient connector', + type: ConnectorTypes.resilient, + fields: null, + }, + }, + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['Denial of Service']); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: { + category: 'Denial of Service', + impact: null, + severity: null, + subcategory: null, + urgency: null, + }, + id: 'servicenow-1', + name: 'My SN connector', + type: '.servicenow', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the custom fields data correctly', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: null, + }, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + onChange: onChangeState, + }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const customFieldsElement = await screen.findByTestId('caseCustomFields'); + + expect( + await within(customFieldsElement).findAllByTestId('form-optional-field-label') + ).toHaveLength( + customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length + ); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[3]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My text test value 1', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: true, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('serializes the custom fields data correctly with existing custom fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'this is my first custom field value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: false, + }, + ], + }, + }, + onChange: onChangeState, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const toggleField = customFieldsConfigurationMock[1]; + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + expect(data).toEqual({ + key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is my first custom field value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, + name: 'Template 1', + tags: [], + }); + }); + }); + + it('shows form state as invalid when template name missing', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), ''); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template name is too long', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const name = 'a'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), name); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template description is too long', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const description = 'a'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), description); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tags are more than 10', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const tagsArray = Array(MAX_TAGS_PER_TEMPLATE + 1).fill('foo'); + + const templateTags = await screen.findByTestId('template-tags'); + + tagsArray.forEach((tag) => { + userEvent.paste(within(templateTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tag is more than 50 characters', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const x = 'a'.repeat(MAX_TEMPLATE_TAG_LENGTH + 1); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), x); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx new file mode 100644 index 0000000000000..acd6855fe4706 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -0,0 +1,75 @@ +/* + * 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 { 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 { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; +import type { FormState } from '../configure_cases/flyout'; +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import { templateDeserializer, templateSerializer } from './utils'; +import type { TemplateFormProps } from './types'; +import type { CasesConfigurationUI } from '../../containers/types'; + +interface Props { + onChange: (state: FormState<TemplateConfiguration, TemplateFormProps>) => void; + initialValue: TemplateConfiguration | null; + connectors: ActionConnector[]; + currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; +} + +const FormComponent: React.FC<Props> = ({ + onChange, + initialValue, + connectors, + currentConfiguration, + isEditMode = false, +}) => { + const keyDefaultValue = useMemo(() => uuidv4(), []); + + const { form } = useForm({ + defaultValue: initialValue ?? { + key: keyDefaultValue, + name: '', + description: '', + tags: [], + caseFields: { + connector: currentConfiguration.connector, + }, + }, + options: { stripEmptyFields: false }, + schema, + deserializer: templateDeserializer, + serializer: (data: TemplateFormProps) => + templateSerializer(connectors, currentConfiguration, data), + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( + <Form form={form}> + <FormFields + isSubmitting={isSubmitting} + connectors={connectors} + currentConfiguration={currentConfiguration} + isEditMode={isEditMode} + /> + </Form> + ); +}; + +FormComponent.displayName = 'TemplateForm'; + +export const TemplateForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx new file mode 100644 index 0000000000000..814ba13efe6ed --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -0,0 +1,398 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { CaseSeverity, ConnectorTypes } from '../../../common/types/domain'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS, CASE_SETTINGS } from './translations'; +import { FormFields } from './form_fields'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('form fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { tags: [], templateTags: [] }; + const defaultProps = { + connectors: connectorsMock, + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all steps', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByText(TEMPLATE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_SETTINGS)).toBeInTheDocument(); + expect(await screen.findByText(CONNECTOR_FIELDS)).toBeInTheDocument(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('renders case fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('renders case fields with existing value', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + title: 'Case title', + description: 'case description', + tags: ['case-1', 'case-2'], + category: 'new', + severity: CaseSeverity.MEDIUM, + templateTags: [], + }} + onSubmit={onSubmit} + > + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case title' + ); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-1'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-2'); + + const category = await screen.findByTestId('caseCategory'); + expect(await within(category).findByTestId('comboBoxSearchInput')).toHaveValue('new'); + expect(await screen.findByTestId('case-severity-selection-medium')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toHaveTextContent('case description'); + }); + + it('renders sync alerts correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('renders default connector correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders connector and its fields correctly', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + }; + + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + }); + + it('does not render sync alerts when feature is not enabled', () => { + appMockRenderer = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('caseSyncAlerts')).not.toBeInTheDocument(); + }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + connectorId: 'none', + tags: [], + syncAlerts: true, + name: 'Template 1', + templateDescription: 'this is a first template', + templateTags: ['first'], + }, + true + ); + }); + }); + + it('calls onSubmit with case fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + connectorId: 'none', + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + connectorId: 'none', + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with connector fields', async () => { + const newProps = { + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + }; + + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('severitySelect'), '3'); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '2'); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + tags: [], + category: null, + connectorId: 'servicenow-1', + fields: { + category: 'software', + severity: '3', + urgency: '2', + subcategory: null, + }, + syncAlerts: true, + templateTags: [], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx new file mode 100644 index 0000000000000..9f28f7b7179b4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiSteps } from '@elastic/eui'; +import { CaseFormFields } from '../case_form_fields'; +import * as i18n from './translations'; +import type { ActionConnector } from '../../containers/configure/types'; +import type { CasesConfigurationUI } from '../../containers/types'; +import { TemplateFields } from './template_fields'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import { Connector } from '../case_form_fields/connector'; + +interface FormFieldsProps { + isSubmitting?: boolean; + connectors: ActionConnector[]; + currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; +} + +const FormFieldsComponent: React.FC<FormFieldsProps> = ({ + isSubmitting = false, + connectors, + currentConfiguration, + isEditMode, +}) => { + const { isSyncAlertsEnabled } = useCasesFeatures(); + const { customFields: configurationCustomFields, templates } = currentConfiguration; + const configurationTemplateTags = templates + .map((template) => (template?.tags?.length ? template.tags : [])) + .flat(); + + const firstStep = useMemo( + () => ({ + title: i18n.TEMPLATE_FIELDS, + children: ( + <TemplateFields + isLoading={isSubmitting} + configurationTemplateTags={configurationTemplateTags} + /> + ), + }), + [isSubmitting, configurationTemplateTags] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.CASE_FIELDS, + children: ( + <CaseFormFields + configurationCustomFields={configurationCustomFields} + isLoading={isSubmitting} + setCustomFieldsOptional={true} + isEditMode={isEditMode} + /> + ), + }), + [isSubmitting, configurationCustomFields, isEditMode] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.CASE_SETTINGS, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( + () => ({ + title: i18n.CONNECTOR_FIELDS, + children: ( + <Connector connectors={connectors} isLoading={isSubmitting} isLoadingConnectors={false} /> + ), + }), + [connectors, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, thirdStep, fourthStep, isSyncAlertsEnabled] + ); + + return ( + <> + <UseField path="key" component={HiddenField} /> + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'template-creation-form-steps'} + /> + </> + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/index.test.tsx b/x-pack/plugins/cases/public/components/templates/index.test.tsx new file mode 100644 index 0000000000000..ca4cb4c3caf83 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -0,0 +1,138 @@ +/* + * 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, within } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import { Templates } from '.'; +import * as i18n from './translations'; +import { templatesConfigurationMock } from '../../containers/mock'; + +describe('Templates', () => { + let appMockRender: AppMockRenderer; + + const props = { + disabled: false, + isLoading: false, + templates: [], + onAddTemplate: jest.fn(), + onEditTemplate: jest.fn(), + onDeleteTemplate: jest.fn(), + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('renders empty templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: [] }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('empty-templates')).toBeInTheDocument(); + expect(await screen.queryByTestId('templates-list')).not.toBeInTheDocument(); + }); + + it('renders templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, isLoading: true }} />); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, disabled: true }} />); + + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(<Templates {...props} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(props.onAddTemplate).toBeCalled(); + }); + + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('shows the experimental badge', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('shows error when templates reaches the limit', async () => { + const mockTemplates = []; + + for (let i = 0; i < MAX_TEMPLATES_LENGTH; i++) { + mockTemplates.push({ + key: `field_key_${i + 1}`, + name: `template_${i + 1}`, + description: 'random foobar', + caseFields: null, + }); + } + + appMockRender.render(<Templates {...{ ...props, templates: mockTemplates }} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByText(i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH))); + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx new file mode 100644 index 0000000000000..9671b9aee8556 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; +import * as i18n from './translations'; +import { TemplatesList } from './templates_list'; + +interface Props { + disabled: boolean; + isLoading: boolean; + templates: CasesConfigurationUITemplate[]; + onAddTemplate: () => void; + onEditTemplate: (key: string) => void; + onDeleteTemplate: (key: string) => void; +} + +const TemplatesComponent: React.FC<Props> = ({ + disabled, + isLoading, + templates, + onAddTemplate, + onEditTemplate, + onDeleteTemplate, +}) => { + const { permissions } = useCasesContext(); + const canAddTemplates = permissions.create && permissions.update; + const [error, setError] = useState<boolean>(false); + + const handleAddTemplate = useCallback(() => { + if (templates.length === MAX_TEMPLATES_LENGTH && !error) { + setError(true); + return; + } + + onAddTemplate(); + setError(false); + }, [onAddTemplate, error, templates]); + + const handleEditTemplate = useCallback( + (key: string) => { + setError(false); + onEditTemplate(key); + }, + [setError, onEditTemplate] + ); + + const handleDeleteTemplate = useCallback( + (key: string) => { + setError(false); + onDeleteTemplate(key); + }, + [setError, onDeleteTemplate] + ); + + return ( + <EuiDescribedFormGroup + fullWidth + title={ + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}>{i18n.TEMPLATE_TITLE}</EuiFlexItem> + <EuiFlexItem grow={false}> + <ExperimentalBadge /> + </EuiFlexItem> + </EuiFlexGroup> + } + description={<p>{i18n.TEMPLATE_DESCRIPTION}</p>} + data-test-subj="templates-form-group" + > + <EuiPanel paddingSize="s" color="subdued" hasBorder={false} hasShadow={false}> + {templates.length ? ( + <> + <TemplatesList + templates={templates} + onEditTemplate={handleEditTemplate} + onDeleteTemplate={handleDeleteTemplate} + /> + {error ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiText color="danger">{i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH)}</EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + </> + ) : null} + <EuiSpacer size="m" /> + {!templates.length ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false} data-test-subj="empty-templates"> + {i18n.NO_TEMPLATES} + <EuiSpacer size="m" /> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + {canAddTemplates ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + isLoading={isLoading} + isDisabled={disabled || error} + size="s" + onClick={handleAddTemplate} + iconType="plusInCircle" + data-test-subj="add-template" + > + {i18n.ADD_TEMPLATE} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + </EuiPanel> + </EuiDescribedFormGroup> + ); +}; + +TemplatesComponent.displayName = 'Templates'; + +export const Templates = React.memo(TemplatesComponent); diff --git a/x-pack/plugins/cases/public/components/templates/schema.test.tsx b/x-pack/plugins/cases/public/components/templates/schema.test.tsx new file mode 100644 index 0000000000000..3e572068b5fdc --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.test.tsx @@ -0,0 +1,115 @@ +/* + * 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 { caseFormFieldsSchemaWithOptionalLabel } from './schema'; + +describe('Template schema', () => { + describe('caseFormFieldsSchemaWithOptionalLabel', () => { + it('has label append for each field', () => { + expect(caseFormFieldsSchemaWithOptionalLabel).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "category": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "connectorId": Object { + "defaultValue": "none", + "label": "External incident management system", + }, + "customFields": Object {}, + "description": Object { + "label": "Description", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + "fields": Object { + "defaultValue": null, + }, + "severity": Object { + "label": "Severity", + }, + "syncAlerts": Object { + "defaultValue": true, + "helpText": "Enabling this option will sync the alert statuses with the case status.", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "tags": Object { + "helpText": "Separate tags with a line break.", + "label": "Tags", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "validator": [Function], + }, + ], + }, + "title": Object { + "label": "Name", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx new file mode 100644 index 0000000000000..2c51bc8827b3b --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.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 { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_TAG_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, +} from '../../../common/constants'; +import { OptionalFieldLabel } from '../optional_field_label'; +import * as i18n from './translations'; +import type { TemplateFormProps } from './types'; +import { + validateEmptyTags, + validateMaxLength, + validateMaxTagsLength, +} from '../case_form_fields/utils'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; +const { emptyField, maxLengthField } = fieldValidators; + +const nonOptionalFields = ['connectorId', 'fields', 'severity', 'customFields']; + +// add optional label to all case form fields +export const caseFormFieldsSchemaWithOptionalLabel = Object.fromEntries( + Object.entries(caseFormFieldsSchema).map(([key, value]) => { + if (typeof value === 'object' && !nonOptionalFields.includes(key)) { + const updatedValue = { ...value, labelAppend: OptionalFieldLabel }; + return [key, updatedValue]; + } + + return [key, value]; + }) +); + +export const schema: FormSchema<TemplateFormProps> = { + key: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD('key')), + }, + ], + }, + name: { + label: i18n.TEMPLATE_NAME, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.TEMPLATE_NAME)), + }, + { + validator: maxLengthField({ + length: MAX_TEMPLATE_NAME_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH), + }), + }, + ], + }, + templateDescription: { + label: i18n.DESCRIPTION, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_TEMPLATE_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH), + }), + }, + ], + }, + templateTags: { + label: i18n.TAGS, + helpText: i18n.TEMPLATE_TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_TEMPLATE_TAG_LENGTH), + limit: MAX_TEMPLATE_TAG_LENGTH, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_TEMPLATE), + limit: MAX_TAGS_PER_TEMPLATE, + }), + }, + ], + }, + ...caseFormFieldsSchemaWithOptionalLabel, +}; diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx new file mode 100644 index 0000000000000..8073c2e25fb41 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx @@ -0,0 +1,141 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { TemplateFields } from './template_fields'; + +describe('Template fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; + const defaultProps = { + isLoading: false, + configurationTemplateTags: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('renders template fields with existing value', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Sample template'); + + const templateTags = await screen.findByTestId('template-tags'); + + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-1' + ); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-2' + ); + + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a template description' + ); + }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Template 1', + templateDescription: 'this is a first template', + templateTags: ['first'], + }, + true + ); + }); + }); + + it('calls onSubmit with updated template fields', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), '!!'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste(await screen.findByTestId('template-description-input'), '..'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Sample template!!', + templateDescription: 'This is a template description..', + templateTags: ['template-1', 'template-2', 'first'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx new file mode 100644 index 0000000000000..2f989201437c3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, TextAreaField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup } from '@elastic/eui'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { TemplateTags } from './template_tags'; + +const TemplateFieldsComponent: React.FC<{ + isLoading: boolean; + configurationTemplateTags: string[]; +}> = ({ isLoading = false, configurationTemplateTags }) => ( + <EuiFlexGroup data-test-subj="template-fields" direction="column" gutterSize="none"> + <UseField + path="name" + component={TextField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'template-name-input', + fullWidth: true, + autoFocus: true, + isLoading, + }, + }} + /> + <TemplateTags isLoading={isLoading} tagOptions={configurationTemplateTags} /> + <UseField + path="templateDescription" + component={TextAreaField} + componentProps={{ + labelAppend: OptionalFieldLabel, + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + isLoading, + }, + }} + /> + </EuiFlexGroup> +); + +TemplateFieldsComponent.displayName = 'TemplateFields'; + +export const TemplateFields = memo(TemplateFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx new file mode 100644 index 0000000000000..6a99321bb7727 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 { TemplateTags } from './template_tags'; +import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl'; + +describe('TemplateTags', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template tags', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + }); + + it('renders loading state', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={true} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + }); + + it('shows template tags options', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={['foo', 'bar', 'test']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + await showEuiComboBoxOptions(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + }); + + it('shows template tags with current values', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + + expect(await screen.findByText('bar')).toBeInTheDocument(); + }); + + it('adds template tag ', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + userEvent.paste(comboBoxEle, 'template'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['test', 'template'], + }, + true + ); + }); + }); + + it('adds new template tag to existing tags', async () => { + appMockRenderer.render( + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['foo', 'bar', 'test'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx new file mode 100644 index 0000000000000..92f141a73eb85 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from './translations'; +interface Props { + isLoading: boolean; + tagOptions: string[]; +} + +const TemplateTagsComponent: React.FC<Props> = ({ isLoading, tagOptions }) => { + const options = tagOptions.map((label) => ({ + label, + })); + + return ( + <UseField + path="templateTags" + component={ComboBoxField} + componentProps={{ + idAria: 'template-tags', + 'data-test-subj': 'template-tags', + euiFieldProps: { + placeholder: '', + fullWidth: true, + disabled: isLoading, + isLoading, + options, + noSuggestions: false, + customOptionText: i18n.ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX, + }, + }} + /> + ); +}; + +TemplateTagsComponent.displayName = 'TemplateTagsComponent'; + +export const TemplateTags = memo(TemplateTagsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx new file mode 100644 index 0000000000000..61f855c427c3c --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx @@ -0,0 +1,145 @@ +/* + * 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 { createAppMockRenderer } from '../../common/mock'; +import { templatesConfigurationMock } from '../../containers/mock'; +import { TemplatesList } from './templates_list'; +import userEvent from '@testing-library/user-event'; + +describe('TemplatesList', () => { + let appMockRender: AppMockRenderer; + const onDeleteTemplate = jest.fn(); + const onEditTemplate = jest.fn(); + + const props = { + templates: templatesConfigurationMock, + onDeleteTemplate, + onEditTemplate, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(<TemplatesList {...props} />); + + expect(screen.getByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders all templates', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: templatesConfigurationMock }} /> + ); + + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + + templatesConfigurationMock.forEach((template) => + expect(screen.getByTestId(`template-${template.key}`)).toBeInTheDocument() + ); + }); + + it('renders template details correctly', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[3]] }} /> + ); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + expect( + await screen.findByTestId(`template-${templatesConfigurationMock[3].key}`) + ).toBeInTheDocument(); + expect(await screen.findByText(`${templatesConfigurationMock[3].name}`)).toBeInTheDocument(); + + const tags = templatesConfigurationMock[3].tags; + + tags?.forEach((tag, index) => + expect( + screen.getByTestId(`${templatesConfigurationMock[3].key}-tag-${index}`) + ).toBeInTheDocument() + ); + }); + + it('renders empty state correctly', () => { + appMockRender.render(<TemplatesList {...{ ...props, templates: [] }} />); + + expect(screen.queryAllByTestId(`template-`, { exact: false })).toHaveLength(0); + }); + + it('renders edit button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ).toBeInTheDocument(); + }); + + it('renders delete button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ).toBeInTheDocument(); + }); + + it('renders delete modal', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + userEvent.click( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(await screen.findByText('Delete')).toBeInTheDocument(); + expect(await screen.findByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx new file mode 100644 index 0000000000000..ceaac643ecab3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -0,0 +1,133 @@ +/* + * 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, + EuiBadge, + useEuiTheme, + EuiButtonIcon, + EuiBadgeGroup, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { TruncatedText } from '../truncated_text'; +import type { TemplateConfiguration, TemplatesConfiguration } from '../../../common/types/domain'; +import { DeleteConfirmationModal } from '../configure_cases/delete_confirmation_modal'; +import * as i18n from './translations'; +export interface Props { + templates: TemplatesConfiguration; + onDeleteTemplate: (key: string) => void; + onEditTemplate: (key: string) => void; +} + +const TemplatesListComponent: React.FC<Props> = (props) => { + const { templates, onEditTemplate, onDeleteTemplate } = props; + const { euiTheme } = useEuiTheme(); + const [itemToBeDeleted, setItemToBeDeleted] = useState<TemplateConfiguration | null>(null); + + const onConfirm = useCallback(() => { + if (itemToBeDeleted) { + onDeleteTemplate(itemToBeDeleted.key); + } + + setItemToBeDeleted(null); + }, [onDeleteTemplate, setItemToBeDeleted, itemToBeDeleted]); + + const onCancel = useCallback(() => { + setItemToBeDeleted(null); + }, []); + + const showModal = Boolean(itemToBeDeleted); + + return templates.length ? ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="flexStart" data-test-subj="templates-list"> + <EuiFlexItem> + {templates.map((template) => ( + <React.Fragment key={template.key}> + <EuiPanel + paddingSize="s" + data-test-subj={`template-${template.key}`} + hasShadow={false} + > + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={true}> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText> + <h4> + <TruncatedText text={template.name} /> + </h4> + </EuiText> + </EuiFlexItem> + <EuiBadgeGroup gutterSize="s"> + {template.tags?.length + ? template.tags.map((tag, index) => ( + <EuiBadge + css={css` + max-width: 100px; + `} + key={`${template.key}-tag-${index}`} + data-test-subj={`${template.key}-tag-${index}`} + color={euiTheme.colors.body} + > + {tag} + </EuiBadge> + )) + : null} + </EuiBadgeGroup> + </EuiFlexGroup> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="flexEnd" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-edit`} + aria-label={`${template.key}-template-edit`} + iconType="pencil" + color="primary" + onClick={() => onEditTemplate(template.key)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-delete`} + aria-label={`${template.key}-template-delete`} + iconType="minusInCircle" + color="danger" + onClick={() => setItemToBeDeleted(template)} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + <EuiSpacer size="s" /> + </React.Fragment> + ))} + </EuiFlexItem> + {showModal && itemToBeDeleted ? ( + <DeleteConfirmationModal + title={i18n.DELETE_TITLE(itemToBeDeleted.name)} + message={i18n.DELETE_MESSAGE(itemToBeDeleted.name)} + onCancel={onCancel} + onConfirm={onConfirm} + /> + ) : null} + </EuiFlexGroup> + </> + ) : null; +}; + +TemplatesListComponent.displayName = 'TemplatesList'; + +export const TemplatesList = React.memo(TemplatesListComponent); diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts new file mode 100644 index 0000000000000..2993070046813 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 TEMPLATE_TITLE = i18n.translate('xpack.cases.templates.title', { + defaultMessage: 'Templates', +}); + +export const TEMPLATE_DESCRIPTION = i18n.translate('xpack.cases.templates.description', { + defaultMessage: + 'Add Case Templates to automatically define the case fields while creating a new case. A user can choose to create an empty case or based on a preset template. Templates allow to auto-populate values when creating new cases.', +}); + +export const NO_TEMPLATES = i18n.translate('xpack.cases.templates.noTemplates', { + defaultMessage: 'You do not have any templates yet', +}); + +export const ADD_TEMPLATE = i18n.translate('xpack.cases.templates.addTemplate', { + defaultMessage: 'Add template', +}); + +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.templates.createTemplate', { + defaultMessage: 'Create template', +}); + +export const REQUIRED = i18n.translate('xpack.cases.templates.required', { + defaultMessage: 'Required', +}); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.templates.requiredField', { + values: { fieldName }, + defaultMessage: 'A {fieldName} is required.', + }); + +export const TEMPLATE_NAME = i18n.translate('xpack.cases.templates.templateName', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_TAGS_HELP = i18n.translate('xpack.cases.templates.templateTagsHelp', { + defaultMessage: + 'Type one or more custom identifying tags for this template. Please enter after each tag to begin a new one', +}); + +export const TEMPLATE_FIELDS = i18n.translate('xpack.cases.templates.templateFields', { + defaultMessage: 'Template fields', +}); + +export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { + defaultMessage: 'Case fields', +}); + +export const CASE_SETTINGS = i18n.translate('xpack.cases.templates.caseSettings', { + defaultMessage: 'Case settings', +}); + +export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { + defaultMessage: 'External Connector Fields', +}); + +export const DELETE_TITLE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteTitle', { + values: { name }, + defaultMessage: 'Delete {name}?', + }); + +export const DELETE_MESSAGE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteMessage', { + values: { name }, + defaultMessage: 'This action will permanently delete {name}.', + }); + +export const MAX_TEMPLATE_LIMIT = (maxTemplates: number) => + i18n.translate('xpack.cases.templates.maxTemplateLimit', { + values: { maxTemplates }, + defaultMessage: 'Maximum number of {maxTemplates} templates reached.', + }); diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts new file mode 100644 index 0000000000000..cf1187ed64e2d --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -0,0 +1,15 @@ +/* + * 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 { TemplateConfiguration } from '../../../common/types/domain'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; + +export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & + Partial<CaseFormFieldsSchemaProps> & { + templateTags?: string[]; + templateDescription?: string; + }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts new file mode 100644 index 0000000000000..9e3cd70c120af --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -0,0 +1,389 @@ +/* + * 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 { CaseSeverity, ConnectorTypes } from '../../../common'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import type { CaseUI } from '../../containers/types'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { + convertTemplateCustomFields, + removeEmptyFields, + templateDeserializer, + templateSerializer, +} from './utils'; + +describe('utils', () => { + describe('getTemplateSerializedData', () => { + it('serializes empty fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + category: null, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], + }); + }); + + it('serializes connectors fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: '', + name: '', + templateDescription: '', + fields: null, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], + }); + }); + + it('serializes non empty fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + templateTags: ['sample'], + category: 'new', + }); + + expect(res).toEqual({ + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: 'description 1', + key: 'key_1', + name: 'template 1', + tags: ['sample'], + }); + }); + + it('serializes custom fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: '', + customFields: { + custom_field_1: 'foobar', + custom_fields_2: '', + custom_field_3: true, + }, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: 'key_1', + name: 'template 1', + tags: [], + }); + }); + + it('serializes connector fields correctly', () => { + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { + key: 'key_1', + name: 'template 1', + templateDescription: '', + fields: { + impact: 'high', + severity: 'low', + category: null, + urgency: null, + subcategory: null, + }, + }); + + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: 'key_1', + name: 'template 1', + tags: [], + }); + }); + }); + + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); + }); + + describe('templateDeserializer', () => { + it('deserialzies initial data correctly', () => { + const res = templateDeserializer({ key: 'temlate_1', name: 'Template 1', caseFields: null }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies template data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + description: 'This is first template', + tags: ['t1', 't2'], + caseFields: null, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: 'This is first template', + templateTags: ['t1', 't2'], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies case fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies custom fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: { + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }, + fields: null, + }); + }); + + it('deserialzies connector data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: { + category: 'software', + urgency: '1', + severity: null, + impact: null, + subcategory: null, + }, + }, + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'servicenow-1', + customFields: {}, + fields: { + category: 'software', + impact: undefined, + severity: undefined, + subcategory: undefined, + urgency: '1', + }, + }); + }); + }); + + describe('convertTemplateCustomFields', () => { + it('converts data correctly', () => { + const data = [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ] as CaseUI['customFields']; + + const res = convertTemplateCustomFields(data); + + expect(res).toEqual({ + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }); + }); + + it('returns null when customFields empty', () => { + const res = convertTemplateCustomFields([]); + + expect(res).toEqual(null); + }); + + it('returns null when customFields undefined', () => { + const res = convertTemplateCustomFields(undefined); + + expect(res).toEqual(null); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts new file mode 100644 index 0000000000000..3ee3002388e2d --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -0,0 +1,120 @@ +/* + * 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 type { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormDeserializer, + getConnectorsFormSerializer, +} from '../utils'; +import type { TemplateFormProps } from './types'; + +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} + +export const convertTemplateCustomFields = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const templateDeserializer = (data: TemplateConfiguration): TemplateFormProps => { + if (data == null) { + return data; + } + + const { key, name, description, tags: templateTags, caseFields } = data; + const { connector, customFields, settings, tags, ...rest } = caseFields ?? {}; + const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); + const convertedCustomFields = customFieldsFormDeserializer(customFields); + + return { + key, + name, + templateDescription: description ?? '', + templateTags: templateTags ?? [], + connectorId: connector?.id ?? 'none', + fields: connectorFields.fields ?? null, + customFields: convertedCustomFields ?? {}, + tags: tags ?? [], + ...rest, + }; +}; + +export const templateSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: TemplateFormProps +): TemplateConfiguration => { + if (data == null) { + return data; + } + + const { fields: connectorFields = null, key, name, ...rest } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields: connectorFields }); + const nonEmptyFields = removeEmptyFields({ ...rest }); + + const { + connectorId, + customFields: templateCustomFields, + syncAlerts = false, + templateTags, + templateDescription, + ...otherCaseFields + } = nonEmptyFields; + + const transformedCustomFields = templateCustomFields + ? customFieldsFormSerializer(templateCustomFields, currentConfiguration.customFields) + : []; + + const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; + + const transformedConnector = templateConnector + ? normalizeActionConnector(templateConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedData: TemplateConfiguration = { + key, + name, + description: templateDescription, + tags: templateTags ?? [], + caseFields: { + ...otherCaseFields, + connector: transformedConnector, + customFields: transformedCustomFields, + settings: { syncAlerts }, + }, + }; + + return transformedData; +}; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 0e7cd9fb03b35..005f15b78b3d7 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,7 +7,14 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { elasticUser, getCaseUsersMockResponse } from '../containers/mock'; +import { + customFieldsConfigurationMock, + customFieldsMock, + elasticUser, + getCaseUsersMockResponse, +} from '../containers/mock'; +import type { CaseUICustomField } from '../containers/types'; +import { CustomFieldTypes } from '../../common/types/domain/custom_field/v1'; import { connectorDeprecationValidator, convertEmptyValuesToNull, @@ -21,6 +28,9 @@ import { stringifyToURL, parseCaseUsers, convertCustomFieldValue, + addOrReplaceField, + removeEmptyFields, + customFieldsFormSerializer, } from './utils'; describe('Utils', () => { @@ -528,4 +538,274 @@ describe('Utils', () => { expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); }); }); + + describe('addOrReplaceField ', () => { + 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 = addOrReplaceField(customFieldsMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": "my_test_value", + }, + ] + ` + ); + }); + + it('updates existing custom field correctly', async () => { + const fieldToUpdate = { + ...customFieldsMock[0], + field: { value: ['My text test value 1!!!'] }, + }; + + const res = addOrReplaceField(customFieldsMock, fieldToUpdate as CaseUICustomField); + expect(res).toMatchInlineSnapshot( + [ + { ...fieldToUpdate }, + { ...customFieldsMock[1] }, + { ...customFieldsMock[2] }, + { ...customFieldsMock[3] }, + ], + ` + Array [ + Object { + "field": Object { + "value": Array [ + "My text test value 1!!!", + ], + }, + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + ] + ` + ); + }); + + 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 = addOrReplaceField(customFieldsConfigurationMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsConfigurationMock, fieldToAdd], + ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "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 = addOrReplaceField(customFieldsConfigurationMock, fieldToUpdate); + expect(res).toMatchInlineSnapshot( + [ + { ...fieldToUpdate }, + { ...customFieldsConfigurationMock[1] }, + { ...customFieldsConfigurationMock[2] }, + { ...customFieldsConfigurationMock[3] }, + ], + ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1!!!", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + ] + ` + ); + }); + }); + + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); + }); + + describe('customFieldsFormSerializer', () => { + it('transforms customFields correctly', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ]); + }); + + it('returns empty array when custom fields are empty', () => { + expect(customFieldsFormSerializer({}, customFieldsConfigurationMock)).toEqual([]); + }); + + it('returns empty array when not custom fields in the configuration', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, [])).toEqual([]); + }); + + it('returns empty array when custom fields do not match with configuration', () => { + const customFields = { + random_key: 'first value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 13bff3b48fdc9..7e1aa54554f50 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -17,7 +17,13 @@ import { ConnectorTypes } from '../../common/types/domain'; import type { CasesPublicStartDependencies } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import type { CaseActionConnector } from './types'; -import type { CaseUser, CaseUsers } from '../../common/ui/types'; +import type { + CasesConfigurationUI, + CaseUI, + CaseUICustomField, + CaseUser, + CaseUsers, +} from '../../common/ui/types'; import { convertToCaseUserWithProfileInfo } from './user_profiles/user_converter'; import type { CaseUserWithProfileInfo } from './user_profiles/types'; @@ -235,3 +241,72 @@ export const convertCustomFieldValue = (value: string | boolean) => { return value; }; + +export const addOrReplaceField = <T extends { key: string }>(fields: T[], fieldToAdd: T): T[] => { + const foundFieldIndex = fields.findIndex((field) => field.key === fieldToAdd.key); + + if (foundFieldIndex === -1) { + return [...fields, fieldToAdd]; + } + + return fields.map((field) => { + if (field.key !== fieldToAdd.key) { + return field; + } + + return fieldToAdd; + }); +}; + +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} + +export const customFieldsFormDeserializer = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const customFieldsFormSerializer = ( + customFields: Record<string, string | boolean>, + selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] +): CaseUI['customFields'] => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !selectedCustomFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = selectedCustomFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index ae72d839d3ac5..b67e8f53f2268 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -115,7 +115,8 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionType const convertConfigureResponseToCasesConfigure = ( configuration: SnakeToCamelCase<Configuration> ): CasesConfigurationUI => { - const { id, version, mappings, customFields, closureType, connector, owner } = configuration; + const { id, version, mappings, customFields, templates, closureType, connector, owner } = + configuration; - return { id, version, mappings, customFields, closureType, connector, owner }; + return { id, version, mappings, customFields, templates, closureType, connector, owner }; }; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index a5946ca319641..1124283e5aa94 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,7 +11,7 @@ import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import type { CaseConnectorMapping } from './types'; import type { CasesConfigurationUI } from '../types'; -import { customFieldsConfigurationMock } from '../mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -49,6 +49,7 @@ export const caseConfigurationResponseMock: Configuration = { owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, }; export const caseConfigurationRequest: ConfigurationRequest = { @@ -74,5 +75,6 @@ export const casesConfigurationsMock: CasesConfigurationUI = { mappings: [], version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, owner: 'securitySolution', }; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts index fdd46d640e5fc..cd9e44d1bdaae 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts @@ -48,6 +48,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', @@ -86,6 +87,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx index e98d63debce4b..0fd0ca642baf2 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx @@ -27,6 +27,7 @@ export function useGetSupportedActionConnectors() { return getSupportedActionConnectors({ signal }); }, { + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( 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 index 509b0e72cd1fc..4fab35fd5ce5f 100644 --- 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 @@ -14,13 +14,14 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { ConnectorTypes } from '../../../common'; import { casesQueriesKeys } from '../constants'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); const useToastMock = useToasts as jest.Mock; -describe('useCreateAttachments', () => { +describe('usePersistConfiguration', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -38,6 +39,7 @@ describe('useCreateAttachments', () => { type: ConnectorTypes.none, }, customFields: [], + templates: [], version: '', id: '', }; @@ -53,7 +55,7 @@ describe('useCreateAttachments', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -61,22 +63,24 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, version: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + templates: [], + }); + }); 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(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -84,14 +88,44 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, id: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + owner: 'securitySolution', + }); + }); 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 with correct data', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id' }); + }); + + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + owner: 'securitySolution', + }); }); }); @@ -99,7 +133,7 @@ describe('useCreateAttachments', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -107,20 +141,50 @@ describe('useCreateAttachments', () => { result.current.mutate({ ...request, id: 'test-id', version: 'test-version' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + version: 'test-version', + }); + }); 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('calls patchCaseConfigure with correct data', async () => { + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); + }); + + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + version: 'test-version', + }); }); }); it('invalidates the queries correctly', async () => { const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -128,13 +192,13 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + }); }); it('shows the success toaster', async () => { - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -142,9 +206,9 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addSuccess).toHaveBeenCalled(); + await waitFor(() => { + expect(addSuccess).toHaveBeenCalled(); + }); }); it('shows a toast error when the api return an error', async () => { @@ -152,7 +216,7 @@ describe('useCreateAttachments', () => { .spyOn(api, 'postCaseConfigure') .mockRejectedValue(new Error('useCreateAttachments: Test error')); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -160,8 +224,8 @@ describe('useCreateAttachments', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addError).toHaveBeenCalled(); + await waitFor(() => { + 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 index 95162d23aa391..dc9bed95d1df8 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -27,12 +27,13 @@ export const usePersistConfiguration = () => { const { showErrorToast, showSuccessToast } = useCasesToast(); return useMutation( - ({ id, version, closureType, customFields, connector }: Request) => { + ({ id, version, closureType, customFields, templates, connector }: Request) => { if (isEmpty(id) || isEmpty(version)) { return postCaseConfigure({ closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], owner: owner[0], }); } @@ -42,6 +43,7 @@ export const usePersistConfiguration = () => { closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], }); }, { diff --git a/x-pack/plugins/cases/public/containers/configure/utils.ts b/x-pack/plugins/cases/public/containers/configure/utils.ts index 164b9c0f94945..e4416beb5ce57 100644 --- a/x-pack/plugins/cases/public/containers/configure/utils.ts +++ b/x-pack/plugins/cases/public/containers/configure/utils.ts @@ -16,6 +16,7 @@ export const initialConfiguration: CasesConfigurationUI = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 63fac9c816955..8d2feca6b9be0 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -45,6 +45,7 @@ import type { AttachmentUI, CaseUICustomField, CasesConfigurationUICustomField, + CasesConfigurationUITemplate, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; @@ -1177,3 +1178,84 @@ export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = { type: CustomFieldTypes.TEXT, key: 'test_key_3', label: 'My test label 3', required: false }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', label: 'My test label 4', required: false }, ]; + +export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: [], + caseFields: {}, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template with few case fields', + tags: ['foo'], + caseFields: { + title: 'This is case title using a test template', + severity: CaseSeverity.MEDIUM, + tags: ['third-template', 'medium'], + }, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_5', + name: 'Fifth test template', + description: 'This is a fifth test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 5', + description: 'case desc', + severity: CaseSeverity.HIGH, + category: 'my category', + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'jira-1', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + }, + }, + }, +]; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts index 5732085d99c8e..4edc105b8d349 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts @@ -114,4 +114,14 @@ describe.skip('useBulkGetUserProfiles', () => { expect(addError).toHaveBeenCalled(); }); + + it('does not call the bulkGetUserProfiles if the array of uids is empty', async () => { + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + + renderHook(() => useBulkGetUserProfiles({ uids: [] }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(spyOnBulkGetUserProfiles).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts index a9e60f3e854a9..8b1b9580ca84f 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts @@ -34,6 +34,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { select: profilesToMap, retry: false, keepPreviousData: true, + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( @@ -44,6 +45,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { ); } }, + enabled: uids.length > 0, } ); }; diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts index 1f6c9b307fe6e..c7f047aa6b385 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts @@ -977,7 +977,7 @@ describe('bulkCreate', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to bulk create cases: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to bulk create cases: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index a3fc842dfe3e1..0109e6eda8808 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -1309,7 +1309,7 @@ describe('update', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); 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 315ee14834574..8b24c79c530b0 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -632,7 +632,7 @@ describe('create', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to create case: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to create case: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/validators.ts b/x-pack/plugins/cases/server/client/cases/validators.ts index eeebbc8c13ca0..b2e286d48c4b1 100644 --- a/x-pack/plugins/cases/server/client/cases/validators.ts +++ b/x-pack/plugins/cases/server/client/cases/validators.ts @@ -9,7 +9,7 @@ import { differenceWith, intersectionWith, isEmpty } from 'lodash'; import Boom from '@hapi/boom'; import type { CustomFieldsConfiguration } from '../../../common/types/domain'; import type { CaseRequestCustomFields, CasesSearchRequest } from '../../../common/types/api'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; import type { ICasesCustomField } from '../../custom_fields'; import { casesCustomFields } from '../../custom_fields'; import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; @@ -20,7 +20,10 @@ interface CustomFieldValidationParams { } export const validateCustomFields = (params: CustomFieldValidationParams) => { - validateDuplicatedCustomFieldKeysInRequest(params); + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: 'customFields', + }); validateCustomFieldKeysAgainstConfiguration(params); validateRequiredCustomFields(params); validateCustomFieldTypesInRequest(params); 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 b5958c44de080..8b312d2d957a2 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -15,8 +15,10 @@ import { createCasesClientInternalMock, createCasesClientMockArgs } from '../moc import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_SUPPORTED_CONNECTORS_RETURNED, + MAX_TEMPLATES_LENGTH, } from '../../../common/constants'; import { ConnectorTypes } from '../../../common'; +import type { TemplatesConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import type { ConfigurationRequest } from '../../../common/types/api'; @@ -306,7 +308,7 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to get patch configure in route: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to get patch configure in route: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); @@ -346,6 +348,618 @@ describe('client', () => { 'Failed to get patch configure in route: Error: Invalid custom field types in request for the following labels: "text label"' ); }); + + describe('templates', () => { + it(`does not throw error when trying to update templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`does not throw error when trying to update to empty templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`throws when trying to update more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: null, + }, + { + key: 'template_1', + name: 'template 2', + tags: [], + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated templates keys in request: template_1' + ); + }); + + describe('customFields', () => { + it('throws when there are no customFields in configure and template has customField in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: The following custom fields have the wrong type in the request: "text label"' + ); + }); + + it('removes deleted custom field from template correctly', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + closure_type: 'close-by-user', + owner: 'cases', + }, + id: 'test-id', + version: 'test-version', + }); + + await update( + 'test-id', + { + version: 'test-version', + customFields: [], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'updated value', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ); + + expect(clientArgs.services.caseConfigureService.patch).toHaveBeenCalledWith({ + configurationId: 'test-id', + originalConfiguration: { + attributes: { + closure_type: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + required: false, + type: 'text', + }, + ], + owner: 'cases', + templates: [ + { + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: 'text', + value: 'custom field value 1', + }, + ], + }, + description: 'this is test description', + key: 'template_1', + name: 'template 1', + }, + ], + }, + id: 'test-id', + version: 'test-version', + }, + unsecuredSavedObjectsClient: expect.anything(), + updatedAttributes: { + customFields: [], + templates: [ + { + caseFields: { + customFields: [], + }, + description: 'this is test description', + key: 'template_1', + name: 'template 1', + }, + ], + updated_at: expect.anything(), + updated_by: expect.anything(), + }, + }); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); describe('create', () => { @@ -404,8 +1018,334 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to create case configuration: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to create case configuration: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); + + describe('templates', () => { + it(`throws when trying to create more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + create( + { + ...baseRequest, + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'duplicated_key', + name: 'template 1', + description: 'test', + caseFields: null, + }, + { + key: 'duplicated_key', + name: 'template 2', + description: 'test', + tags: [], + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated templates keys in request: duplicated_key' + ); + }); + + describe('customFields', () => { + it('does not throw error when creating template with correct custom fields', async () => { + const customFields = [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ]; + const templates: TemplatesConfiguration = [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ]; + + clientArgs.services.caseConfigureService.find.mockResolvedValueOnce({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + score: 0, + }, + ], + pit_id: undefined, + }); + + clientArgs.services.caseConfigureService.post.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + }); + + await expect( + create( + { + ...baseRequest, + customFields, + templates, + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it('throws when there are no customFields in configure and template has customField in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + tags: ['foo', 'bar'], + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: The following custom fields have the wrong type in the request: "custom field 1"' + ); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + tags: [], + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 2fc0cc3e72590..68db617af8bc2 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -18,6 +18,8 @@ import type { ConfigurationAttributes, Configurations, ConnectorMappings, + CustomFieldsConfiguration, + TemplatesConfiguration, } from '../../../common/types/domain'; import type { ConfigurationPatchRequest, @@ -42,13 +44,17 @@ import type { CasesClientArgs } from '../types'; import { getMappings } from './get_mappings'; import { Operations } from '../../authorization'; -import { combineAuthorizedAndOwnerFilter } from '../utils'; +import { combineAuthorizedAndOwnerFilter, removeCustomFieldFromTemplates } from '../utils'; import type { MappingsArgs, CreateMappingsArgs, UpdateMappingsArgs } from './types'; import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; +import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; /** * Defines the internal helper functions. @@ -91,6 +97,52 @@ export interface ConfigureSubClient { create(configuration: ConfigurationRequest): Promise<Configuration>; } +/** + * validate templates in configuration + */ +const validateTemplates = async ({ + templates, + clientArgs, + customFields, +}: { + templates: TemplatesConfiguration | undefined; + clientArgs: CasesClientArgs; + customFields: CustomFieldsConfiguration | undefined; +}) => { + const { licensingService } = clientArgs.services; + + validateDuplicatedKeysInRequest({ + requestFields: templates, + fieldName: 'templates', + }); + + if (templates && templates.length) { + /** + * Assign users to a template is only available to Platinum+ + */ + const hasAssigneesInTemplate = templates.some((template) => + Boolean(template.caseFields?.assignees && template.caseFields?.assignees.length > 0) + ); + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (hasAssigneesInTemplate && !hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + } + + if (hasAssigneesInTemplate) { + licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); + } + + validateTemplatesCustomFieldsInRequest({ + templates, + customFieldsConfiguration: customFields, + }); + } +}; + /** * These functions should not be exposed on the plugin contract. They are for internal use to support the CRUD of * configurations. @@ -251,9 +303,12 @@ export async function update( try { const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); - validateDuplicatedCustomFieldKeysInRequest({ requestCustomFields: request.customFields }); + validateDuplicatedKeysInRequest({ + requestFields: request.customFields, + fieldName: 'customFields', + }); - const { version, ...queryWithoutVersion } = request; + const { version, templates, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ unsecuredSavedObjectsClient, @@ -265,6 +320,17 @@ export async function update( originalCustomFields: configuration.attributes.customFields, }); + await validateTemplates({ + templates, + clientArgs, + customFields: configuration.attributes.customFields, + }); + + const updatedTemplates = removeCustomFieldFromTemplates({ + templates, + customFields: request.customFields, + }); + await authorization.ensureAuthorized({ operation: Operations.updateConfiguration, entities: [{ owner: configuration.attributes.owner, id: configuration.id }], @@ -320,6 +386,7 @@ export async function update( configurationId: configuration.id, updatedAttributes: { ...queryWithoutVersionAndConnector, + ...(updatedTemplates && { templates: updatedTemplates }), ...(connector != null && { connector }), updated_at: updateDate, updated_by: user, @@ -364,8 +431,15 @@ export async function create( const validatedConfigurationRequest = decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest); - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: validatedConfigurationRequest.customFields, + validateDuplicatedKeysInRequest({ + requestFields: validatedConfigurationRequest.customFields, + fieldName: 'customFields', + }); + + await validateTemplates({ + templates: validatedConfigurationRequest.templates, + clientArgs, + customFields: validatedConfigurationRequest.customFields, }); let error = null; @@ -441,6 +515,7 @@ export async function create( attributes: { ...validatedConfigurationRequest, customFields: validatedConfigurationRequest.customFields ?? [], + templates: validatedConfigurationRequest.templates ?? [], 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 index 0f8e20505fb39..ca81926519d37 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.test.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.test.ts @@ -6,10 +6,16 @@ */ import { CustomFieldTypes } from '../../../common/types/domain'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; describe('validators', () => { describe('validateCustomFieldTypesInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('throws an error with the keys of customFields in request that have invalid types', () => { expect(() => validateCustomFieldTypesInRequest({ @@ -69,4 +75,303 @@ describe('validators', () => { ).not.toThrow(); }); }); + + describe('validateTemplatesCustomFieldsInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields types in request match the configuration', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: { + title: 'Case title with template 2', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + tags: ['first-template'], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: null, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no configuration found but no templates are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [], + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateTemplatesCustomFieldsInRequest({})).not.toThrow(); + }); + + it('throws if configuration is missing and template has custom fields', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + + it('throws for a single invalid type', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\""` + ); + }); + + it('throws for multiple custom fields with invalid types', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + 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: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + label: 'second label', + required: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + label: 'third label', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\", \\"second label\\", \\"third label\\""` + ); + }); + + it('throws if there are invalid custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid custom field keys: invalid_key"`); + }); + + it('throws if template has duplicated custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated templates[0]'s customFields keys in request: first_key"` + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/configure/validators.ts b/x-pack/plugins/cases/server/client/configure/validators.ts index c5929065c631b..1dec647561ab8 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.ts @@ -6,7 +6,16 @@ */ import Boom from '@hapi/boom'; -import type { CustomFieldTypes } from '../../../common/types/domain'; +import type { + CustomFieldsConfiguration, + CustomFieldTypes, + TemplatesConfiguration, +} from '../../../common/types/domain'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldKeysAgainstConfiguration, + validateCustomFieldTypesInRequest as validateCaseCustomFieldTypesInRequest, +} from '../cases/validators'; /** * Throws an error if the request tries to change the type of existing custom fields. @@ -38,3 +47,41 @@ export const validateCustomFieldTypesInRequest = ({ ); } }; + +export const validateTemplatesCustomFieldsInRequest = ({ + templates, + customFieldsConfiguration, +}: { + templates?: TemplatesConfiguration; + customFieldsConfiguration?: CustomFieldsConfiguration; +}) => { + if (!Array.isArray(templates) || !templates.length) { + return; + } + + templates.forEach((template, index) => { + if ( + !template.caseFields || + !template.caseFields.customFields || + !template.caseFields.customFields.length + ) { + return; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + const params = { + requestCustomFields: template.caseFields.customFields, + customFieldsConfiguration, + }; + + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: `templates[${index}]'s customFields`, + }); + validateCustomFieldKeysAgainstConfiguration(params); + validateCaseCustomFieldTypesInRequest(params); + }); +}; diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index 8f9e8648a1269..56615189d1d5e 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -19,6 +19,7 @@ import { constructQueryOptions, constructSearch, convertSortField, + removeCustomFieldFromTemplates, } from './utils'; import { CasePersistedSeverity, CasePersistedStatus } from '../common/types/case'; import type { CustomFieldsConfiguration } from '../../common/types/domain'; @@ -1130,4 +1131,289 @@ describe('utils', () => { ); }); }); + + describe('removeCustomFieldFromTemplates', () => { + const customFields = [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + label: 'My test label 1', + required: true, + defaultValue: 'My default value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + label: 'My test label 2', + required: true, + defaultValue: true, + }, + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_3', + label: 'My test label 3', + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'My default value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: false, + }, + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_3', + value: 'Test custom field', + }, + ], + }, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: [], + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: 'My value', + }, + { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_2', + value: true, + }, + ], + }, + }, + ]; + + it('removes custom field from template correctly', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My default value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: false, + }, + ], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + ], + }, + }, + ]); + }); + + it('removes multiple custom fields from template correctly', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [customFields[0]], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My default value', + }, + ], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My value', + }, + ], + }, + }, + ]); + }); + + it('removes all custom fields from templates when custom fields are empty', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: [], + }); + + expect(res).toEqual([ + { + caseFields: { + customFields: [], + }, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + description: 'This is a second test template', + key: 'test_template_2', + name: 'Second test template', + tags: [], + caseFields: { + customFields: [], + }, + }, + ]); + }); + + it('removes all custom fields from templates when custom fields are undefined', () => { + const res = removeCustomFieldFromTemplates({ + templates, + customFields: undefined, + }); + + expect(res).toEqual([ + { ...templates[0], caseFields: { customFields: [] } }, + { ...templates[1], caseFields: { ...templates[1].caseFields, customFields: [] } }, + ]); + }); + + it('does not remove custom field when templates do not have custom fields', () => { + const res = removeCustomFieldFromTemplates({ + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + }, + }, + ], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + caseFields: null, + description: 'This is a first test template', + key: 'test_template_1', + name: 'First test template', + }, + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + description: 'this is test', + title: 'Test title', + }, + }, + ]); + }); + + it('does not remove custom field when templates have empty custom fields', () => { + const res = removeCustomFieldFromTemplates({ + templates: [ + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + customFields: [], + }, + }, + ], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([ + { + key: 'test_template_2', + name: 'Second test template', + caseFields: { + title: 'Test title', + description: 'this is test', + customFields: [], + }, + }, + ]); + }); + + it('does not remove custom field from empty templates', () => { + const res = removeCustomFieldFromTemplates({ + templates: [], + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([]); + }); + + it('returns empty array when templates are undefined', () => { + const res = removeCustomFieldFromTemplates({ + templates: undefined, + customFields: [customFields[0], customFields[1]], + }); + + expect(res).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 0ce4da8bcc21b..258761a563fd3 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -21,6 +21,7 @@ import type { CaseStatuses, CustomFieldsConfiguration, ExternalReferenceAttachmentPayload, + TemplatesConfiguration, } from '../../common/types/domain'; import { ActionsAttachmentPayloadRt, @@ -604,3 +605,37 @@ export const constructSearch = ( return { search }; }; + +/** + * remove deleted custom field from template + */ +export const removeCustomFieldFromTemplates = ({ + templates, + customFields, +}: { + templates?: TemplatesConfiguration; + customFields?: CustomFieldsConfiguration; +}): TemplatesConfiguration => { + if (!templates || !templates.length) { + return []; + } + + return templates.map((template) => { + if (!template.caseFields?.customFields || !template.caseFields?.customFields.length) { + return template; + } + + if (!customFields || !customFields?.length) { + return { ...template, caseFields: { ...template.caseFields, customFields: [] } }; + } + + const templateCustomFields = template.caseFields.customFields.filter((templateCustomField) => + customFields?.find((customField) => customField.key === templateCustomField.key) + ); + + return { + ...template, + caseFields: { ...template.caseFields, customFields: templateCustomFields }, + }; + }); +}; diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts index 8d6caa218f932..77867aedbcb4a 100644 --- a/x-pack/plugins/cases/server/client/validators.test.ts +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { validateDuplicatedCustomFieldKeysInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from './validators'; describe('validators', () => { - describe('validateDuplicatedCustomFieldKeysInRequest', () => { - it('returns customFields in request that have duplicated keys', () => { + describe('validateDuplicatedKeysInRequest', () => { + it('returns fields in request that have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: 'triplicated_key', }, @@ -29,16 +29,18 @@ describe('validators', () => { key: 'duplicated_key', }, ], + + fieldName: 'foobar', }) ).toThrowErrorMatchingInlineSnapshot( - `"Invalid duplicated custom field keys in request: triplicated_key,duplicated_key"` + `"Invalid duplicated foobar keys in request: triplicated_key,duplicated_key"` ); }); - it('does not throw if no customFields in request have duplicated keys', () => { + it('does not throw if no fields in request have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: '1', }, @@ -46,6 +48,7 @@ describe('validators', () => { key: '2', }, ], + fieldName: 'foobar', }) ).not.toThrow(); }); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts index 88b62640cee88..24527ac81155b 100644 --- a/x-pack/plugins/cases/server/client/validators.ts +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -10,15 +10,17 @@ import Boom from '@hapi/boom'; /** * Throws an error if the request has custom fields with duplicated keys. */ -export const validateDuplicatedCustomFieldKeysInRequest = ({ - requestCustomFields = [], +export const validateDuplicatedKeysInRequest = ({ + requestFields = [], + fieldName, }: { - requestCustomFields?: Array<{ key: string }>; + requestFields?: Array<{ key: string }>; + fieldName: string; }) => { const uniqueKeys = new Set<string>(); const duplicatedKeys = new Set<string>(); - requestCustomFields.forEach((item) => { + requestFields.forEach((item) => { if (uniqueKeys.has(item.key)) { duplicatedKeys.add(item.key); } else { @@ -28,7 +30,7 @@ export const validateDuplicatedCustomFieldKeysInRequest = ({ if (duplicatedKeys.size > 0) { throw Boom.badRequest( - `Invalid duplicated custom field keys in request: ${Array.from(duplicatedKeys.values())}` + `Invalid duplicated ${fieldName} keys in request: ${Array.from(duplicatedKeys.values())}` ); } }; diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 94dcaf0a9ce19..faf2517fbe173 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -8,14 +8,19 @@ import * as rt from 'io-ts'; import type { SavedObject } from '@kbn/core/server'; -import type { ConfigurationAttributes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + CaseSeverity, + ConfigurationAttributes, +} from '../../../common/types/domain'; import { ConfigurationActivityFieldsRt, ConfigurationAttributesRt, ConfigurationBasicWithoutOwnerRt, } from '../../../common/types/domain'; import type { ConnectorPersisted } from './connectors'; -import type { User } from './user'; +import type { User, UserProfile } from './user'; export interface ConfigurationPersistedAttributes { connector: ConnectorPersisted; @@ -26,6 +31,7 @@ export interface ConfigurationPersistedAttributes { updated_at: string | null; updated_by: User | null; customFields?: PersistedCustomFieldsConfiguration; + templates?: PersistedTemplatesConfiguration; } type PersistedCustomFieldsConfiguration = Array<{ @@ -36,6 +42,26 @@ type PersistedCustomFieldsConfiguration = Array<{ defaultValue?: string | boolean | null; }>; +type PersistedTemplatesConfiguration = Array<{ + key: string; + name: string; + description?: string; + tags?: string[]; + caseFields?: CaseFieldsAttributes | null; +}>; + +export interface CaseFieldsAttributes { + title?: string; + assignees?: UserProfile[]; + connector?: CaseConnector; + description?: string; + severity?: CaseSeverity; + tags?: string[]; + category?: string | null; + customFields?: CaseCustomFields; + settings?: { syncAlerts: boolean }; +} + export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject<ConfigurationTransformedAttributes>; 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 d1a79cc1a8d6e..627263de50849 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -5,8 +5,12 @@ * 2.0. */ -import type { CaseConnector, ConfigurationAttributes } from '../../../common/types/domain'; -import { CustomFieldTypes, ConnectorTypes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + ConfigurationAttributes, +} from '../../../common/types/domain'; +import { CustomFieldTypes, ConnectorTypes, CaseSeverity } from '../../../common/types/domain'; import { CASE_CONFIGURE_SAVED_OBJECT, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { @@ -59,6 +63,40 @@ const basicConfigFields = { defaultValue: 'foobar', }, ], + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ] as CaseCustomFields, + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + ], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial<ConfigurationAttributes> => ({ @@ -204,6 +242,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", @@ -490,6 +568,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 6c367d9a96848..f50ac271bc4ff 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -228,12 +228,17 @@ function transformToExternalModel( ? [] : (configuration.attributes.customFields as ConfigurationTransformedAttributes['customFields']); + const templates = !configuration.attributes.templates + ? [] + : (configuration.attributes.templates as ConfigurationTransformedAttributes['templates']); + return { ...configuration, attributes: { ...castedAttributes, connector, customFields, + templates, }, }; } 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 c74c6be78da82..99479082f6559 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 @@ -43,6 +43,7 @@ export const getConfigurationRequest = ({ closure_type: 'close-by-user', owner: 'securitySolutionFixture', customFields: [], + templates: [], ...overrides, }; }; 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 4a9e99016c801..5ccf9015839c7 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,7 +5,11 @@ * 2.0. */ -import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -80,6 +84,61 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should return a configuration with templates', async () => { + const templates = { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + tags: [], + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: ['foobar'], + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }; + + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: templates, + }) + ); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, templates)); + }); + 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 c8e0f092edf3a..114182a1ad20d 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,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { ConfigurationPatchRequest } from '@kbn/cases-plugin/common/types/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -127,6 +131,163 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); + it('should patch a configuration with templates', async () => { + const customFieldsConfiguration = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + tags: ['foo', 'bar'], + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + tags: [], + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates']; + + const configuration = await createConfiguration(supertest, { + ...getConfigurationRequest(), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + }); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields: customFieldsConfiguration, + templates, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + templates, + }); + }); + + it('should remove custom fields from templates', async () => { + const customFieldsConfiguration = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + ]; + + const configuration = await createConfiguration(supertest, { + ...getConfigurationRequest(), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + }); + + // delete custom fields + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields: [], + templates: templates as ConfigurationPatchRequest['templates'], + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + customFields: [], + templates: [ + { ...templates[0], caseFields: { ...templates[0].caseFields, customFields: [] } }, + ], + }); + }); + describe('validation', () => { it('should not patch a configuration with unsupported connector type', async () => { const configuration = await createConfiguration(supertest); @@ -270,6 +431,64 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not update a configuration with templates with custom fields that don't exist in the configuration", async () => { + const configuration = await createConfiguration(supertest); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + 400 + ); + }); + + it('should not patch a configuration with duplicated template keys', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates'], + }, + 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 a7461d5f1fc18..8a81214f009d6 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,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + 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'; @@ -98,6 +102,84 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should create a configuration with templates', async () => { + const customFields = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + tags: ['foobar'], + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ]; + + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { customFields, templates }, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql({ ...getConfigurationOutput(false), customFields, templates }); + }); + it('should keep only the latest configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); @@ -410,6 +492,61 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not create a configuration with templates with custom fields that don't exist in the configuration", async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + }), + 400 + ); + }); + + it('should not create a configuration with duplicated template keys', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }, + }), + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts index 72a65bc98cb61..7a1d4f52108d1 100644 --- a/x-pack/test/functional/services/cases/api.ts +++ b/x-pack/test/functional/services/cases/api.ts @@ -161,5 +161,23 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { }) ); }, + + async createConfigWithTemplates({ + templates, + owner, + }: { + templates: Configuration['templates']; + owner: string; + }) { + return createConfiguration( + kbnSupertest, + getConfigurationRequest({ + overrides: { + templates, + owner, + }, + }) + ); + }, }; } diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index fb018615dd194..3f7b6e1e65f94 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -58,6 +58,10 @@ export function CasesCreateViewServiceProvider( category, owner, }: CreateCaseParams) { + if (owner) { + await this.setSolution(owner); + } + await this.setTitle(title); await this.setDescription(description); await this.setTags(tag); @@ -70,10 +74,6 @@ export function CasesCreateViewServiceProvider( await this.setSeverity(severity); } - if (owner) { - await this.setSolution(owner); - } - await this.submitCase(); }, @@ -96,7 +96,8 @@ export function CasesCreateViewServiceProvider( }, async setSolution(owner: string) { - await testSubjects.click(`${owner}RadioButton`); + await testSubjects.click('caseOwnerSuperSelect'); + await testSubjects.click(`${owner}OwnerOption`); }, async setSeverity(severity: CaseSeverity) { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index fcb1e23d6f9bb..c9a16b6e45983 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -93,7 +93,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { 'The length of the tag is too long. The maximum length is 256 characters.' ); - const category = await testSubjects.find('case-create-form-category'); + const category = await testSubjects.find('caseCategory'); expect(await category.getVisibleText()).contain( 'The length of the category is too long. The maximum length is 50 characters.' ); @@ -150,7 +150,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); @@ -207,7 +207,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts index 8c4dd47532255..c714cdba25637 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts @@ -235,8 +235,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('renders solutions selection', async () => { await openFlyout(); + await testSubjects.click('caseOwnerSelector'); + for (const owner of TOTAL_OWNERS) { - await testSubjects.existOrFail(`${owner}RadioButton`); + await testSubjects.existOrFail(`${owner}OwnerOption`); } await closeFlyout(); 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 29eb8c991952a..ee013b882c487 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 @@ -15,7 +15,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); const header = getPageObject('header'); + const comboBox = getService('comboBox'); const find = getService('find'); + const retry = getService('retry'); describe('Configure', function () { before(async () => { @@ -81,13 +83,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -105,7 +107,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -119,12 +121,111 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + before(async () => { + await cases.api.createConfigWithTemplates({ + templates: [ + { + key: 'o11y_template', + name: 'My template 1', + description: 'this is my first template', + tags: ['foo'], + caseFields: null, + }, + ], + owner: 'observability', + }); + }); + + it('existing configurations do not interfere', async () => { + // A configuration created in o11y should not be visible in stack + expect(await testSubjects.getVisibleText('empty-templates')).to.be( + 'You do not have any templates yet' + ); + }); + + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; 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 6506b0985ee20..9c84a9067fbfe 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 @@ -33,6 +33,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { updated_at: null, updated_by: null, customFields: [], + templates: [], mappings: [ { source: 'title', target: 'summary', action_type: 'overwrite' }, { source: 'description', target: 'description', action_type: 'overwrite' }, diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts index aa154cd15b036..6f99331f91813 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_case.ts @@ -65,6 +65,7 @@ export const attachTimeline = (newCase: TestCase) => { cy.get('body').type('{esc}'); cy.get(INSERT_TIMELINE_BTN).click(); cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get('[data-test-subj="selectable-input"]').click(); cy.get(TIMELINE_SEARCHBOX).should('exist'); cy.get(TIMELINE_SEARCHBOX).should('be.visible'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index 44cce6cfb520d..d7d0f30da7502 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -20,6 +20,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { @@ -75,13 +76,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -99,7 +100,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -113,12 +114,89 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts index 67b7cd1f3dfb3..9377238535b40 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts @@ -96,7 +96,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index bd36f8f7a8ea1..478cb6d78f775 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -22,6 +22,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { @@ -76,13 +77,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -100,7 +101,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -114,12 +115,89 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts index 27e4fda20f5ec..4662e96c401f2 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts @@ -97,7 +97,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description');