[ResponseOps][Cases] Allow users to create case using templates (#187138)

## Summary

Fixes https://github.com/elastic/kibana/issues/181309

This PR 
- allows users to create, edit or delete templates via cases > settings
page
- allows users to create case using templates


39226aa4-9d9a-41a8-a900-ca765ed98e1b

## Testing

1. Go to all solutions and create cases with all fields (including all
fields of all supported connectors) without using templates. Verify that
everything is working as expected.
2. Go to all solutions and create and edit templates with various
fields. Verify that everything is working as expected.
3. Go to all solutions, create different templates on each solution, and
verify that when creating a case you can use templates and everything is
working as expected.
4. Go to the alerts table of o11y and security and attach alerts to a
new case. Verify that in the flyout the templates are working as
expected.
5. Go to ML and try to attach an ML visualization to a new case. Verify
that the solution picker is working as expected and it resets the form
when changing solutions.
6. Create a template with custom fields. Delete one of the custom fields
from the settings page. Verify that it is also deleted from the
template.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

**Flaky test runner**:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6425

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)


## Release notes
Allow users to create case using templates.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: adcoelho <antonio.coelho@elastic.co>
This commit is contained in:
Janki Salvi 2024-07-02 11:45:49 +01:00 committed by GitHub
parent e73eb1d33e
commit 8bf9aa56b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 11704 additions and 2524 deletions

View file

@ -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

View file

@ -56,8 +56,8 @@ export const OWNER_INFO: Record<Owner, RouteInfo> = {
[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],
},

View file

@ -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
*/

View file

@ -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}.`
);
});
});
});
});

View file

@ -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 }),

View file

@ -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<typeof CaseAttributesRt>;
export type CaseSettings = rt.TypeOf<typeof CaseSettingsRt>;
export type RelatedCase = rt.TypeOf<typeof RelatedCaseRt>;
export type AttachmentTotals = rt.TypeOf<typeof AttachmentTotalsRt>;
export type CaseBaseOptionalFields = rt.TypeOf<typeof CaseBaseOptionalFieldsRt>;

View file

@ -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: {} },
});
});
});
});

View file

@ -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<typeof CustomFieldsConfigurationRt>;
export type CustomFieldConfiguration = rt.TypeOf<typeof CustomFieldConfigurationRt>;
export type TemplatesConfiguration = rt.TypeOf<typeof TemplatesConfigurationRt>;
export type TemplateConfiguration = rt.TypeOf<typeof TemplateConfigurationRt>;
export type ClosureType = rt.TypeOf<typeof ClosureTypeRt>;
export type ConfigurationAttributes = rt.TypeOf<typeof ConfigurationAttributesRt>;
export type Configuration = rt.TypeOf<typeof ConfigurationRt>;

View file

@ -119,10 +119,18 @@ export interface ResolvedCase {
export type CasesConfigurationUI = Pick<
SnakeToCamelCase<Configuration>,
'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';

View file

@ -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<PropsWithChildren<unknown>> = ({ children }) => {
const { form } = useForm<FormProps>();
const { form } = useForm();
globalForm = form;
return <Form form={form}>{children}</Form>;
@ -41,113 +40,99 @@ describe('Assignees', () => {
});
it('renders', async () => {
const result = appMockRender.render(
appMockRender.render(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
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(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
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(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
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(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
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(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
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();
});
act(() => {
userEvent.click(screen.getByTestId('comboBoxSearchInput'));
});
userEvent.click(await screen.findByTestId('comboBoxSearchInput'));
await waitFor(() => {
expect(screen.getByText('Turtle')).toBeInTheDocument();
expect(screen.getByText('turtle')).toBeInTheDocument();
});
expect(await screen.findByText('Turtle')).toBeInTheDocument();
expect(await screen.findByText('turtle')).toBeInTheDocument();
act(() => {
userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true });
});
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(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(
<MockHookWrapperComponent>
<Assignees isLoading={false} />
</MockHookWrapperComponent>
);
expect(screen.queryByText(userProfile.user.full_name)).not.toBeInTheDocument();
act(() => {
globalForm.setFieldValue('assignees', [{ uid: userProfile.uid }]);
});
await waitFor(() => {
expect(globalForm.getFormData()).toEqual({
assignees: [{ uid: userProfile.uid }],
});
});
await waitFor(() => {
expect(spyOnBulkGetUserProfiles).toBeCalledTimes(1);
expect(spyOnBulkGetUserProfiles).toHaveBeenCalledWith({
security: expect.anything(),
uids: [userProfile.uid],
});
});
expect(await screen.findByText(userProfile.user.full_name)).toBeInTheDocument();
});
});

View file

@ -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<string> & UserProfileWithAvatar;
interface FieldProps {
field: FieldHook;
options: EuiComboBoxOptionOption[];
field: FieldHook<CaseAssignees>;
options: UserProfileComboBoxOption[];
isLoading: boolean;
isDisabled: boolean;
currentUserProfile?: UserProfile;
selectedOptions: EuiComboBoxOptionOption[];
setSelectedOptions: React.Dispatch<React.SetStateAction<EuiComboBoxOptionOption[]>>;
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<string>) => ({
uid: option.value ?? '',
});
const AssigneesFieldComponent: React.FC<FieldProps> = 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<EuiComboBoxOptionOption<string>>) => {
setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option)));
},
[setSelectedOptions, setValue]
[setValue]
);
const onSelfAssign = useCallback(() => {
@ -102,62 +110,51 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo(
return;
}
setSelectedOptions((prev) => [
...(prev ?? []),
userProfileToComboBoxOption(currentUserProfile),
]);
setValue([...selectedAssignees, { uid: currentUserProfile.uid }]);
}, [currentUserProfile, selectedAssignees, setValue]);
setValue([
...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []),
{ uid: currentUserProfile.uid },
]);
}, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]);
const renderOption = useCallback((option, searchValue: string, contentClassName: string) => {
const { user, data } = option as UserProfileComboBoxOption;
const renderOption = useCallback(
(option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => {
const { user, data } = option as EuiComboBoxOptionOption<string> & UserProfileWithAvatar;
const displayName = getUserDisplayName(user);
const displayName = getUserDisplayName(user);
return (
return (
<EuiFlexGroup
alignItems="center"
justifyContent="flexStart"
gutterSize="s"
responsive={false}
>
<EuiFlexItem grow={false}>
<UserAvatar user={user} avatar={data.avatar} size="s" />
</EuiFlexItem>
<EuiFlexGroup
alignItems="center"
justifyContent="flexStart"
gutterSize="s"
justifyContent="spaceBetween"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false}>
<UserAvatar user={user} avatar={data.avatar} size="s" />
<EuiFlexItem>
<EuiHighlight search={searchValue} className={contentClassName}>
{displayName}
</EuiHighlight>
</EuiFlexItem>
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
gutterSize="none"
responsive={false}
>
<EuiFlexItem>
<EuiHighlight search={searchValue} className={contentClassName}>
{displayName}
</EuiHighlight>
{user.email && user.email !== displayName ? (
<EuiFlexItem grow={false}>
<EuiTextColor color={'subdued'}>
<EuiHighlight search={searchValue} className={contentClassName}>
{user.email}
</EuiHighlight>
</EuiTextColor>
</EuiFlexItem>
{user.email && user.email !== displayName ? (
<EuiFlexItem grow={false}>
<EuiTextColor color={'subdued'}>
<EuiHighlight search={searchValue} className={contentClassName}>
{user.email}
</EuiHighlight>
</EuiTextColor>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
) : null}
</EuiFlexGroup>
);
},
[]
);
</EuiFlexGroup>
);
}, []);
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<FieldProps> = React.memo(
}
isInvalid={isInvalid}
error={errorMessage}
data-test-subj="caseAssignees"
>
<EuiComboBox
fullWidth
@ -202,9 +200,9 @@ AssigneesFieldComponent.displayName = 'AssigneesFieldComponent';
const AssigneesComponent: React.FC<Props> = ({ 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<EuiComboBoxOptionOption[]>();
const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping();
const hasOwners = owners.length > 0;
@ -212,7 +210,7 @@ const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => {
useGetCurrentUserProfile();
const {
data: userProfiles,
data: userProfiles = [],
isLoading: isLoadingSuggest,
isFetching: isFetchingSuggest,
} = useSuggestUserProfiles({
@ -221,10 +219,22 @@ const AssigneesComponent: React.FC<Props> = ({ 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<Props> = ({ isLoading: isLoadingForm }) => {
const isLoading =
isLoadingForm ||
isLoadingCurrentUserProfile ||
isLoadingBulkGetUserProfiles ||
isLoadingSuggest ||
isFetchingSuggest ||
isUserTyping;
const isDisabled = isLoadingForm || isLoadingCurrentUserProfile;
const isDisabled = isLoadingForm || isLoadingCurrentUserProfile || isLoadingBulkGetUserProfiles;
return (
<UseField
path="assignees"
path={FIELD_ID}
config={getConfig()}
component={AssigneesFieldComponent}
componentProps={{
isLoading,
isDisabled,
selectedOptions,
setSelectedOptions,
options,
onSearchComboChange,
currentUserProfile,

View file

@ -11,7 +11,6 @@ import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import type { FormProps } from './schema';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { Category } from './category';
@ -28,7 +27,7 @@ describe('Category', () => {
const onSubmit = jest.fn();
const FormComponent: FC<PropsWithChildren<unknown>> = ({ children }) => {
const { form } = useForm<FormProps>({ onSubmit });
const { form } = useForm({ onSubmit });
return (
<Form form={form}>

View file

@ -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;

View file

@ -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(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument();
expect(screen.queryByTestId('connector-fields')).not.toBeInTheDocument();
});
it('renders loading state correctly', async () => {
appMockRender.render(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...{ ...defaultProps, isLoading: true }} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={{ connectorId: connectorsMock[2].id }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={{ connectorId: 'none' }}>
<Connector {...defaultProps} />
</FormTestComponent>
);
expect(screen.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument();
expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument();
});
});

View file

@ -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<Props> = ({ 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<Props> = ({ connectors, isLoading, isLoadingC
}
return (
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="connectorId"
config={connectorIdConfig}
component={ConnectorSelector}
defaultValue={defaultConnectorId}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
disabled: isLoading || isLoadingConnectors,
idAria: 'caseConnectors',
isLoading: isLoading || isLoadingConnectors,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<ConnectorFieldsForm connector={connector} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow fullWidth>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="connectorId"
config={connectorIdConfig}
component={ConnectorSelector}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
disabled: isLoading || isLoadingConnectors,
idAria: 'caseConnectors',
isLoading: isLoading || isLoadingConnectors,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<ConnectorFieldsForm connector={connector} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};

View file

@ -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(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} />
<CustomFields {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} />
<CustomFields
isLoading={false}
setCustomFieldsOptional={false}
configurationCustomFields={[]}
/>
</FormTestComponent>
);
@ -78,26 +64,51 @@ describe('CustomFields', () => {
expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0);
});
it('should sort the custom fields correctly', async () => {
const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse();
useGetAllCaseConfigurationsMock.mockImplementation(() => ({
...useGetAllCaseConfigurationsResponse,
data: [
{
...useGetAllCaseConfigurationsResponse.data[0],
customFields: reversedCustomFieldsConfiguration,
},
],
}));
it('should render as optional fields for text custom fields', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} />
<CustomFields
isLoading={false}
configurationCustomFields={customFieldsConfigurationMock}
setCustomFieldsOptional={true}
/>
</FormTestComponent>
);
const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields');
expect(screen.getAllByTestId('form-optional-field-label')).toHaveLength(2);
});
it('should not set default value when in edit mode', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields
isLoading={false}
configurationCustomFields={[customFieldsConfigurationMock[0]]}
setCustomFieldsOptional={false}
isEditMode={true}
/>
</FormTestComponent>
);
expect(
screen.queryByText(`${customFieldsConfigurationMock[0].defaultValue}`)
).not.toBeInTheDocument();
});
it('should sort the custom fields correctly', async () => {
const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse();
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields
isLoading={false}
setCustomFieldsOptional={false}
configurationCustomFields={reversedCustomFieldsConfiguration}
/>
</FormTestComponent>
);
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(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} />
<CustomFields {...defaultProps} />
</FormTestComponent>
);

View file

@ -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<Props> = ({
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 (
<CreateComponent
isLoading={isLoading}
customFieldConfiguration={customField}
key={customField.key}
setAsOptional={setCustomFieldsOptional}
setDefaultValue={!isEditMode}
/>
);
}
);
if (!configurationCustomFields.length) {
return null;
}
return (
<EuiFormRow fullWidth>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText size="m">
<h3>{i18n.ADDITIONAL_FIELDS}</h3>
</EuiText>
<EuiSpacer size="xs" />
<EuiFlexItem data-test-subj="caseCustomFields">{customFieldsComponents}</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};
CustomFieldsComponent.displayName = 'CustomFields';
export const CustomFields = React.memo(CustomFieldsComponent);
const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => {
return sortBy(configCustomFields, (configCustomField) => {
return configCustomField.label;
});
};

View file

@ -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';

View file

@ -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';

View file

@ -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(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument();
});
it('renders case fields correctly', async () => {
appMock.render(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
expect(screen.queryByTestId('caseCustomFields')).not.toBeInTheDocument();
});
it('renders customFields when not empty', async () => {
appMock.render(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields
isLoading={false}
configurationCustomFields={customFieldsConfigurationMock}
/>
</FormTestComponent>
);
expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument();
});
it('does not render assignees when no platinum license', () => {
appMock.render(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument();
});
it('calls onSubmit with case fields', async () => {
appMock.render(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...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(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(
<FormTestComponent
formDefaultValue={{
title: 'Case with Template 1',
description: 'This is a case description',
tags: ['case-tag-1', 'case-tag-2'],
category: null,
}}
onSubmit={onSubmit}
>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...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(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(
<FormTestComponent
formDefaultValue={{
customFields: { [customFieldsConfigurationMock[0].key]: 'Test custom filed value' },
tags: [],
}}
onSubmit={onSubmit}
>
<CaseFormFields {...newProps} />
</FormTestComponent>
);
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(
<FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
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(
<FormTestComponent
formDefaultValue={{
assignees: [{ uid: userProfiles[1].uid }],
tags: [],
}}
onSubmit={onSubmit}
>
<CaseFormFields {...defaultProps} />
</FormTestComponent>
);
userEvent.click(await screen.findByText('Submit'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith(
{
category: null,
tags: [],
assignees: [{ uid: userProfiles[1].uid }],
},
true
);
});
});
});

View file

@ -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<Props> = ({
isLoading,
configurationCustomFields,
setCustomFieldsOptional = false,
isEditMode,
draftStorageKey,
}) => {
const { caseAssignmentAuthorized } = useCasesFeatures();
return (
<EuiFlexGroup data-test-subj="case-form-fields" direction="column" gutterSize="none">
<Title isLoading={isLoading} />
{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);

View file

@ -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,
},
};

View file

@ -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
);
});
});
});

View file

@ -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',

View file

@ -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,

View file

@ -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',

View file

@ -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,

View file

@ -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,
},

View file

@ -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',
});

View file

@ -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();
});
});
});

View file

@ -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,
};
}
};

View file

@ -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 {

View file

@ -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');
});
});
});

View file

@ -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]

View file

@ -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

View file

@ -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: '',

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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';

View file

@ -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();

View file

@ -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>

View file

@ -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',
});

View file

@ -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,
});

View file

@ -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) =>

View file

@ -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}>

View file

@ -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 options = issues.map((issue) => ({ label: issue.title, value: issue.key }));
const onSearchComboChange = (value: string) => {
if (!isEmpty(value)) {
setQuery(value);
}
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);

View file

@ -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();
});
});

View file

@ -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>;

View file

@ -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]

View file

@ -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

View file

@ -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);
});
});

View file

@ -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;
});
};

View file

@ -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>
);
appMockRenderer.render(<CreateCaseForm {...casesFormProps} />);
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();
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>
);
appMockRenderer = createAppMockRenderer({ owner: [] });
appMockRenderer.render(<CreateCaseForm {...casesFormProps} />);
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();
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();
});
});
});

View file

@ -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';
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';
const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) =>
big
? css`
${logicalCSS('margin-top', euiTheme.size.xl)};
`
: css`
${logicalCSS('margin-top', euiTheme.size.base)};
`;
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 shouldShowOwnerSelector = Boolean(!owner.length && availableOwners.length > 1);
const { reset } = useFormContext();
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 { data: connectors = [], isLoading: isLoadingConnectors } =
useGetSupportedActionConnectors();
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 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"
{shouldShowOwnerSelector && (
<CreateCaseOwnerSelector
selectedOwner={selectedOwner}
availableOwners={availableOwners}
isLoading={isLoadingCaseConfiguration}
onOwnerChange={onOwnerChange}
/>
)}
{withSteps ? (
<EuiSteps
headingElement="h2"
steps={allSteps}
data-test-subj={'case-creation-form-steps'}
/>
) : (
<>
{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>

View file

@ -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'
);

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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();
userEvent.click(await screen.findByTestId('caseOwnerSuperSelect'));
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.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>
);
expect(await screen.findByLabelText(OWNER_INFO[disabledButton].label)).toBeDisabled();
expect(await screen.findByLabelText(OWNER_INFO[permission].label)).not.toBeDisabled();
});
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();
expect(await screen.findByText(OWNER_INFO[available].label)).toBeInTheDocument();
userEvent.click(await screen.findByTestId('form-test-component-submit-button'));
userEvent.click(await screen.findByTestId('caseOwnerSuperSelect'));
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.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);
});
});
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');
});
});
});

View file

@ -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' });
isLoading,
onOwnerChange,
selectedOwner,
}) => {
const onChange = (owner: string) => {
onOwnerChange(owner);
};
const onChange = useCallback((val: string) => field.setValue(val), [field]);
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);

View file

@ -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),
}),
},
...(caseFormFieldsSchemaTyped.description.validations ?? []),
],
},
selectedOwner: {
label: i18n.SOLUTION,
type: FIELD_TYPES.RADIO_GROUP,
validations: [
{
validator: emptyField(i18n.SOLUTION_REQUIRED),
},
],
},
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: {},
};

View file

@ -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');
});
});
});

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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:',
});

View file

@ -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',
},
});
});
});
});

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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}
/>

View file

@ -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'
);
});
});
});

View file

@ -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);

View file

@ -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]} />

View file

@ -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;
}

View file

@ -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}>

View file

@ -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,

View file

@ -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}>

View file

@ -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={{

View file

@ -30,6 +30,8 @@ export interface CustomFieldType<T extends CaseUICustomField> {
Create: React.FC<{
customFieldConfiguration: CasesConfigurationUICustomField;
isLoading: boolean;
setAsOptional?: boolean;
setDefaultValue?: boolean;
}>;
}

View file

@ -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 = {

View file

@ -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 => {

View file

@ -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();

View file

@ -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) => {

View file

@ -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]

View file

@ -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">

View file

@ -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({});
});
});
});

View file

@ -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);

View file

@ -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
);
});
});
});

View file

@ -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);

View file

@ -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');
});
});

View file

@ -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);

View file

@ -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],
},
],
},
}
`);
});
});
});

View file

@ -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,
};

View file

@ -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
);
});
});
});

View file

@ -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);

View file

@ -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
);
});
});
});

View file

@ -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);

Some files were not shown because too many files have changed in this diff Show more