[Cases] Custom fields MVP (#167016)

## Summary

This PR implements an MPV for custom fields in Cases. Specifically:

- Users can add, delete, or edit custom fields from the configuration
page
- Users can set custom fields when creating a case
- Users can set custom fields when editing a case
- Only the `Text` custom field type is supported. The `Toggle` is
implemented to test the framework.
- Users with a basic license can enter the configuration page and set
custom fields
- The configuration page header changed to "Settings"
- The "Edit external connection" button changed to "Settings"
- APIs:
    - Users cannot post custom fields with duplicate keys
    - Users cannot change the type of the custom field
    - Users cannot add custom fields that are not configured
    - Required fields should be present
- If some of the custom fields are omitted from the request the backend
will fill them with `null` values
- Limits:
    - Maximum 10 custom fields configured
    - Maximum 50 chars in custom field labels
    - Maximum 160 chars for the value of the `Text` custom field type
    - Users cannot change the type of a custom field
- The key of the custom fields should contain only lowercase letters
(a-z), numbers (0-9), '_', and '-'
    - Required fields needs a value

## Testing
- Cases created before custom fields are working as expected (create
some on `main` before switching to the feature branch)
- Environments without configuration are working as expected (clear ES
data)
- Environments with existing configurations are working as expected
- Try to create cases with custom fields (required & optional) and
delete some of them
- Try to create cases with custom fields (required & optional) and then
configure new custom fields. Existing cases with old custom fields
should work as expected
- User actions are working as expected especially with the combinations
described in the previous two bullets
- Users with a basic license can configure custom fields but cannot
configure connectors
- Users with a gold+ license can configure custom fields and connectors
- Users with read access cannot configure or edit custom fields

Resolves: https://github.com/elastic/kibana/issues/160236

PRs:

- https://github.com/elastic/kibana/pull/165671
- https://github.com/elastic/kibana/pull/166353
- https://github.com/elastic/kibana/pull/166439
- https://github.com/elastic/kibana/pull/166483
- https://github.com/elastic/kibana/pull/166815
- https://github.com/elastic/kibana/pull/166940
- https://github.com/elastic/kibana/pull/166962
- https://github.com/elastic/kibana/pull/166969
- https://github.com/elastic/kibana/pull/166975
- https://github.com/elastic/kibana/pull/167029
- https://github.com/elastic/kibana/pull/167047
- https://github.com/elastic/kibana/pull/167105
- https://github.com/elastic/kibana/pull/167131
- https://github.com/elastic/kibana/pull/167144
- https://github.com/elastic/kibana/pull/167166
- https://github.com/elastic/kibana/pull/167167
- https://github.com/elastic/kibana/pull/167310
- https://github.com/elastic/kibana/pull/167386
- https://github.com/elastic/kibana/pull/167481
- https://github.com/elastic/kibana/pull/167495
- https://github.com/elastic/kibana/pull/167398
- https://github.com/elastic/kibana/pull/167472

### 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]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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

### 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 configure custom fields in Cases. Supported field types:
Text. Coming soon: Multi-text, List, Number, Toggle, Date, IP, Email,
etc.

---------

Co-authored-by: adcoelho <antonio.coelho@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Julian Gernun <17549662+jcger@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-10-02 15:47:10 +02:00 committed by GitHub
parent 41e54e7208
commit 8da8c475f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
177 changed files with 11205 additions and 1788 deletions

View file

@ -1370,6 +1370,41 @@
},
"category": {
"type": "keyword"
},
"customFields": {
"type": "nested",
"properties": {
"key": {
"type": "keyword"
},
"type": {
"type": "keyword"
},
"value": {
"type": "keyword",
"fields": {
"number": {
"type": "long",
"ignore_malformed": true
},
"boolean": {
"type": "boolean",
"ignore_malformed": true
},
"string": {
"type": "text"
},
"date": {
"type": "date",
"ignore_malformed": true
},
"ip": {
"type": "ip",
"ignore_malformed": true
}
}
}
}
}
}
},

View file

@ -69,7 +69,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8",
"canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c",
"canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179",
"cases": "b43a8ce985c406167e1d115381805a48cb3b0e61",
"cases": "2392189ed338857d4815a4cef6051f9ad124d39d",
"cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25",
"cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf",
"cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25",

View file

@ -128,6 +128,11 @@ export const MAX_CASES_TO_UPDATE = 100 as const;
export const MAX_BULK_CREATE_ATTACHMENTS = 100 as const;
export const MAX_USER_ACTIONS_PER_CASE = 10000 as const;
export const MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES = 100 as const;
export const MAX_CUSTOM_FIELDS_PER_CASE = 10 as const;
export const MAX_CUSTOM_FIELD_KEY_LENGTH = 36 as const; // uuidv4 length
export const MAX_CUSTOM_FIELD_LABEL_LENGTH = 50 as const;
export const MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH = 160 as const;
export const MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS = 10 as const;
/**
* Cases features

View file

@ -153,3 +153,26 @@ export const limitedNumberSchema = ({ fieldName, min, max }: LimitedSchemaType)
}),
rt.identity
);
export interface RegexStringSchemaType {
codec: rt.Type<string, string, unknown>;
pattern: string;
message: string;
}
export const regexStringRt = ({ codec, pattern, message }: RegexStringSchemaType) =>
new rt.Type<string, string, unknown>(
'RegexString',
codec.is,
(input, context) =>
either.chain(codec.validate(input, context), (value) => {
const regex = new RegExp(pattern, 'g');
if (!regex.test(value)) {
return rt.failure(input, context, message);
}
return rt.success(value);
}),
rt.identity
);

View file

@ -16,12 +16,16 @@ import {
MAX_LENGTH_PER_TAG,
MAX_TITLE_LENGTH,
MAX_CATEGORY_LENGTH,
MAX_CUSTOM_FIELDS_PER_CASE,
MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH,
} from '../../../constants';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { AttachmentType } from '../../domain/attachment/v1';
import type { Case } from '../../domain/case/v1';
import { CaseSeverity, CaseStatuses } from '../../domain/case/v1';
import { ConnectorTypes } from '../../domain/connector/v1';
import { CasesStatusRequestRt, CasesStatusResponseRt } from '../stats/v1';
import type { CasePostRequest } from './v1';
import {
AllReportersFindRequestRt,
CasePatchRequestRt,
@ -37,8 +41,9 @@ import {
CasesFindResponseRt,
CasesPatchRequestRt,
} from './v1';
import { CustomFieldTypes } from '../../domain/custom_field/v1';
const basicCase = {
const basicCase: Case = {
owner: 'cases',
closed_at: null,
closed_by: null,
@ -96,8 +101,349 @@ const basicCase = {
// damaged_raccoon uid
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
category: null,
customFields: [
{
key: 'first_custom_field_key',
type: CustomFieldTypes.TEXT,
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: CustomFieldTypes.TOGGLE,
value: true,
},
{
key: 'second_custom_field_key',
type: CustomFieldTypes.TEXT,
value: ['www.example.com'],
},
],
};
describe('CasePostRequestRt', () => {
const defaultRequest: CasePostRequest = {
description: 'A description',
tags: ['new', 'case'],
title: 'My new case',
connector: {
id: '123',
name: 'My connector',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
settings: {
syncAlerts: true,
},
owner: 'cases',
severity: CaseSeverity.LOW,
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
customFields: [
{
key: 'first_custom_field_key',
type: CustomFieldTypes.TEXT,
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: CustomFieldTypes.TOGGLE,
value: true,
},
],
};
it('has expected attributes in request', () => {
const query = CasePostRequestRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from request', () => {
const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from connector', () => {
const query = CasePostRequestRt.decode({
...defaultRequest,
connector: { ...defaultRequest.connector, foo: 'bar' },
});
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => {
const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' });
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees }))
).toContain('The length of the field assignees is too long. Array must be of length <= 10.');
});
it('does not throw an error with empty assignees', async () => {
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] }))
).toContain('No errors!');
});
it('does not throw an error with undefined assignees', async () => {
const { assignees, ...rest } = defaultRequest;
expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!');
});
it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => {
const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description }))
).toContain('The length of the description is too long. The maximum length is 30000.');
});
it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => {
const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar');
expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain(
'The length of the field tags is too long. Array must be of length <= 200.'
);
});
it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => {
const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] }))
).toContain('The length of the tag is too long. The maximum length is 256.');
});
it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => {
const title = 'a'.repeat(MAX_TITLE_LENGTH + 1);
expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain(
'The length of the title is too long. The maximum length is 160.'
);
});
it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => {
const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category }))
).toContain('The length of the category is too long. The maximum length is 50.');
});
it('removes foo:bar attributes from customFields', () => {
const customField = {
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
};
const query = CasePostRequestRt.decode({
...defaultRequest,
customFields: [{ ...customField, foo: 'bar' }],
});
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, customFields: [{ ...customField }] },
});
});
it('removes foo:bar attributes from field inside customFields', () => {
const customField = {
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
};
const query = CasePostRequestRt.decode({
...defaultRequest,
customFields: [{ ...customField, foo: 'bar' }],
});
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, customFields: [{ ...customField }] },
});
});
it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => {
const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
});
expect(
PathReporter.report(
CasePostRequestRt.decode({
...defaultRequest,
customFields,
})
)
).toContain(
`The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.`
);
});
it('does not throw an error with undefined customFields', async () => {
const { customFields, ...rest } = defaultRequest;
expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!');
});
it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => {
expect(
PathReporter.report(
CasePostRequestRt.decode({
...defaultRequest,
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)],
},
],
})
)
).toContain(
`The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.`
);
});
});
describe('CasesFindRequestRt', () => {
const defaultRequest = {
tags: ['new', 'case'],
status: CaseStatuses.open,
severity: CaseSeverity.LOW,
assignees: ['damaged_racoon'],
reporters: ['damaged_racoon'],
defaultSearchOperator: 'AND',
from: 'now',
page: '1',
perPage: '10',
search: 'search text',
searchFields: ['title', 'description'],
to: '1w',
sortOrder: 'desc',
sortField: 'createdAt',
owner: 'cases',
};
it('has expected attributes in request', () => {
const query = CasesFindRequestRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, page: 1, perPage: 10 },
});
});
it('removes foo:bar attributes from request', () => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, page: 1, perPage: 10 },
});
});
const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys);
it.each(searchFields)('succeeds with %s as searchFields', (field) => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 },
});
});
const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys);
it.each(sortFields)('succeeds with %s as sortField', (sortField) => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, sortField, page: 1, perPage: 10 },
});
});
it('removes rootSearchField when passed', () => {
expect(
PathReporter.report(
CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] })
)
).toContain('No errors!');
});
describe('errors', () => {
it('throws error when invalid searchField passed', () => {
expect(
PathReporter.report(
CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' })
)
).not.toContain('No errors!');
});
it('throws error when invalid sortField passed', () => {
expect(
PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' }))
).not.toContain('No errors!');
});
it('succeeds when valid parameters passed', () => {
expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain(
'No errors!'
);
});
it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => {
const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain(
'The length of the field category is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => {
const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain(
'The length of the field tags is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => {
const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain(
'The length of the field assignees is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => {
const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain(
'The length of the field reporters is too long. Array must be of length <= 100.'
);
});
});
});
describe('Status', () => {
describe('CasesStatusRequestRt', () => {
const defaultRequest = {
@ -150,238 +496,6 @@ describe('Status', () => {
});
});
});
describe('CasePostRequestRt', () => {
const defaultRequest = {
description: 'A description',
tags: ['new', 'case'],
title: 'My new case',
connector: {
id: '123',
name: 'My connector',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
settings: {
syncAlerts: true,
},
owner: 'cases',
severity: CaseSeverity.LOW,
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
};
it('has expected attributes in request', () => {
const query = CasePostRequestRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from request', () => {
const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from connector', () => {
const query = CasePostRequestRt.decode({
...defaultRequest,
connector: { ...defaultRequest.connector, foo: 'bar' },
});
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => {
const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' });
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees }))
).toContain('The length of the field assignees is too long. Array must be of length <= 10.');
});
it('does not throw an error with empty assignees', async () => {
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] }))
).toContain('No errors!');
});
it('does not throw an error with undefined assignees', async () => {
const { assignees, ...rest } = defaultRequest;
expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!');
});
it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => {
const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description }))
).toContain('The length of the description is too long. The maximum length is 30000.');
});
it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => {
const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar');
expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain(
'The length of the field tags is too long. Array must be of length <= 200.'
);
});
it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => {
const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] }))
).toContain('The length of the tag is too long. The maximum length is 256.');
});
it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => {
const title = 'a'.repeat(MAX_TITLE_LENGTH + 1);
expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain(
'The length of the title is too long. The maximum length is 160.'
);
});
it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => {
const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1);
expect(
PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category }))
).toContain('The length of the category is too long. The maximum length is 50.');
});
});
describe('CasesFindRequestRt', () => {
const defaultRequest = {
tags: ['new', 'case'],
status: CaseStatuses.open,
severity: CaseSeverity.LOW,
assignees: ['damaged_racoon'],
reporters: ['damaged_racoon'],
defaultSearchOperator: 'AND',
from: 'now',
page: '1',
perPage: '10',
search: 'search text',
searchFields: ['title', 'description'],
to: '1w',
sortOrder: 'desc',
sortField: 'createdAt',
owner: 'cases',
};
it('has expected attributes in request', () => {
const query = CasesFindRequestRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, page: 1, perPage: 10 },
});
});
it('removes foo:bar attributes from request', () => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, page: 1, perPage: 10 },
});
});
const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys);
it.each(searchFields)('succeeds with %s as searchFields', (field) => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 },
});
});
const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys);
it.each(sortFields)('succeeds with %s as sortField', (sortField) => {
const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, sortField, page: 1, perPage: 10 },
});
});
it('removes rootSearchField when passed', () => {
expect(
PathReporter.report(
CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] })
)
).toContain('No errors!');
});
describe('errors', () => {
it('throws error when invalid searchField passed', () => {
expect(
PathReporter.report(
CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' })
)
).not.toContain('No errors!');
});
it('throws error when invalid sortField passed', () => {
expect(
PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' }))
).not.toContain('No errors!');
});
it('succeeds when valid parameters passed', () => {
expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain(
'No errors!'
);
});
it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => {
const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain(
'The length of the field category is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => {
const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain(
'The length of the field tags is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => {
const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain(
'The length of the field assignees is too long. Array must be of length <= 100.'
);
});
it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => {
const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar');
expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain(
'The length of the field reporters is too long. Array must be of length <= 100.'
);
});
});
});
});
describe('CasesByAlertIDRequestRt', () => {
@ -478,6 +592,18 @@ describe('CasePatchRequestRt', () => {
id: 'basic-case-id',
version: 'WzQ3LDFd',
description: 'Updated description',
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: 'toggle',
value: true,
},
],
};
it('has expected attributes in request', () => {
@ -555,6 +681,44 @@ describe('CasePatchRequestRt', () => {
PathReporter.report(CasePatchRequestRt.decode({ ...defaultRequest, category }))
).toContain('The length of the category is too long. The maximum length is 50.');
});
it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => {
const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
});
expect(
PathReporter.report(
CasePatchRequestRt.decode({
...defaultRequest,
customFields,
})
)
).toContain(
`The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.`
);
});
it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => {
expect(
PathReporter.report(
CasePatchRequestRt.decode({
...defaultRequest,
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)],
},
],
})
)
).toContain(
`The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.`
);
});
});
describe('CasesPatchRequestRt', () => {

View file

@ -21,6 +21,9 @@ import {
MAX_BULK_GET_CASES,
MAX_CATEGORY_FILTER_LENGTH,
MAX_ASSIGNEES_PER_CASE,
MAX_CUSTOM_FIELDS_PER_CASE,
MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH,
MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS,
} from '../../../constants';
import {
limitedStringSchema,
@ -28,6 +31,7 @@ import {
NonEmptyString,
paginationSchema,
} from '../../../schema';
import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt } from '../../domain';
import {
CaseRt,
CaseSettingsRt,
@ -40,10 +44,35 @@ import { CaseConnectorRt } from '../../domain/connector/v1';
import { CaseUserProfileRt, UserRt } from '../../domain/user/v1';
import { CasesStatusResponseRt } from '../stats/v1';
const CaseCustomFieldWithValidationValueRt = limitedArraySchema({
codec: limitedStringSchema({
fieldName: 'value',
min: 0,
max: MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH,
}),
fieldName: 'value',
min: 0,
max: MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS,
});
const CaseCustomFieldTextWithValidationRt = rt.strict({
key: rt.string,
type: CustomFieldTextTypeRt,
value: rt.union([CaseCustomFieldWithValidationValueRt, rt.null]),
});
const CustomFieldRt = rt.union([CaseCustomFieldTextWithValidationRt, CaseCustomFieldToggleRt]);
const CustomFieldsRt = limitedArraySchema({
codec: CustomFieldRt,
fieldName: 'customFields',
min: 0,
max: MAX_CUSTOM_FIELDS_PER_CASE,
});
/**
* Create case
*/
export const CasePostRequestRt = rt.intersection([
rt.strict({
/**
@ -104,6 +133,10 @@ export const CasePostRequestRt = rt.intersection([
limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }),
rt.null,
]),
/**
* The list of custom field values of the case.
*/
customFields: CustomFieldsRt,
})
),
]);
@ -358,6 +391,10 @@ export const CasePatchRequestRt = rt.intersection([
limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }),
rt.null,
]),
/**
* Custom fields of the case
*/
customFields: CustomFieldsRt,
})
),
/**
@ -448,3 +485,4 @@ export type GetReportersResponse = rt.TypeOf<typeof GetReportersResponseRt>;
export type CasesBulkGetRequest = rt.TypeOf<typeof CasesBulkGetRequestRt>;
export type CasesBulkGetResponse = rt.TypeOf<typeof CasesBulkGetResponseRt>;
export type GetRelatedCasesByAlertResponse = rt.TypeOf<typeof GetRelatedCasesByAlertResponseRt>;
export type CaseRequestCustomFields = rt.TypeOf<typeof CustomFieldsRt>;

View file

@ -5,12 +5,23 @@
* 2.0.
*/
import { PathReporter } from 'io-ts/lib/PathReporter';
import { v4 as uuidv4 } from 'uuid';
import {
MAX_CUSTOM_FIELDS_PER_CASE,
MAX_CUSTOM_FIELD_KEY_LENGTH,
MAX_CUSTOM_FIELD_LABEL_LENGTH,
} from '../../../constants';
import { ConnectorTypes } from '../../domain/connector/v1';
import { CustomFieldTypes } from '../../domain/custom_field/v1';
import {
CaseConfigureRequestParamsRt,
ConfigurationPatchRequestRt,
ConfigurationRequestRt,
GetConfigurationFindRequestRt,
CustomFieldConfigurationWithoutTypeRt,
TextCustomFieldConfigurationRt,
ToggleCustomFieldConfigurationRt,
} from './v1';
describe('configure', () => {
@ -37,6 +48,47 @@ describe('configure', () => {
});
});
it('has expected attributes in request with customFields', () => {
const request = {
...defaultRequest,
customFields: [
{
key: 'text_custom_field',
label: 'Text custom field',
type: CustomFieldTypes.TEXT,
required: false,
},
{
key: 'toggle_custom_field',
label: 'Toggle custom field',
type: CustomFieldTypes.TOGGLE,
required: false,
},
],
};
const query = ConfigurationRequestRt.decode(request);
expect(query).toStrictEqual({
_tag: 'Right',
right: request,
});
});
it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => {
const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({
key: 'text_custom_field',
label: 'Text custom field',
type: CustomFieldTypes.TEXT,
required: false,
});
expect(
PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, customFields }))[0]
).toContain(
`The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}`
);
});
it('removes foo:bar attributes from request', () => {
const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' });
@ -63,6 +115,49 @@ describe('configure', () => {
});
});
it('has expected attributes in request with customFields', () => {
const request = {
...defaultRequest,
customFields: [
{
key: 'text_custom_field',
label: 'Text custom field',
type: CustomFieldTypes.TEXT,
required: false,
},
{
key: 'toggle_custom_field',
label: 'Toggle custom field',
type: CustomFieldTypes.TOGGLE,
required: false,
},
],
};
const query = ConfigurationPatchRequestRt.decode(request);
expect(query).toStrictEqual({
_tag: 'Right',
right: request,
});
});
it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => {
const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({
key: 'text_custom_field',
label: 'Text custom field',
type: CustomFieldTypes.TEXT,
required: false,
});
expect(
PathReporter.report(
ConfigurationPatchRequestRt.decode({ ...defaultRequest, customFields })
)[0]
).toContain(
`The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}`
);
});
it('removes foo:bar attributes from request', () => {
const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' });
@ -120,4 +215,136 @@ describe('configure', () => {
});
});
});
describe('CustomFieldConfigurationWithoutTypeRt', () => {
const defaultRequest = {
key: 'custom_field_key',
label: 'Custom field label',
required: false,
};
it('has expected attributes in request', () => {
const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('limits key to 36 characters', () => {
const longKey = 'x'.repeat(MAX_CUSTOM_FIELD_KEY_LENGTH + 1);
expect(
PathReporter.report(
CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key: longKey })
)
).toContain('The length of the key is too long. The maximum length is 36.');
});
it('returns an error if they key is not in the expected format', () => {
const key = 'Not a proper key';
expect(
PathReporter.report(
CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key })
)
).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`);
});
it('accepts a uuid as a key', () => {
const key = uuidv4();
const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, key },
});
});
it('accepts a slug as a key', () => {
const key = 'abc_key-1';
const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest, key },
});
});
it('limits label to 50 characters', () => {
const longLabel = 'x'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1);
expect(
PathReporter.report(
CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, label: longLabel })
)
).toContain('The length of the label is too long. The maximum length is 50.');
});
});
describe('TextCustomFieldConfigurationRt', () => {
const defaultRequest = {
key: 'my_text_custom_field',
label: 'Text Custom Field',
type: CustomFieldTypes.TEXT,
required: true,
};
it('has expected attributes in request', () => {
const query = TextCustomFieldConfigurationRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
});
describe('ToggleCustomFieldConfigurationRt', () => {
const defaultRequest = {
key: 'my_toggle_custom_field',
label: 'Toggle Custom Field',
type: CustomFieldTypes.TOGGLE,
required: false,
};
it('has expected attributes in request', () => {
const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
});
});

View file

@ -6,10 +6,74 @@
*/
import * as rt from 'io-ts';
import {
MAX_CUSTOM_FIELDS_PER_CASE,
MAX_CUSTOM_FIELD_KEY_LENGTH,
MAX_CUSTOM_FIELD_LABEL_LENGTH,
} from '../../../constants';
import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema';
import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain';
import type { Configurations, Configuration } from '../../domain/configure/v1';
import { ConfigurationBasicWithoutOwnerRt, CasesConfigureBasicRt } from '../../domain/configure/v1';
import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1';
import { CaseConnectorRt } from '../../domain/connector/v1';
export const ConfigurationRequestRt = CasesConfigureBasicRt;
export const CustomFieldConfigurationWithoutTypeRt = rt.strict({
/**
* key of custom field
*/
key: regexStringRt({
codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_CUSTOM_FIELD_KEY_LENGTH }),
pattern: '^[a-z0-9_-]+$',
message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`,
}),
/**
* label of custom field
*/
label: limitedStringSchema({ fieldName: 'label', min: 1, max: MAX_CUSTOM_FIELD_LABEL_LENGTH }),
/**
* custom field options - required
*/
required: rt.boolean,
});
export const TextCustomFieldConfigurationRt = rt.intersection([
rt.strict({ type: CustomFieldTextTypeRt }),
CustomFieldConfigurationWithoutTypeRt,
]);
export const ToggleCustomFieldConfigurationRt = rt.intersection([
rt.strict({ type: CustomFieldToggleTypeRt }),
CustomFieldConfigurationWithoutTypeRt,
]);
export const CustomFieldsConfigurationRt = limitedArraySchema({
codec: rt.union([TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt]),
min: 0,
max: MAX_CUSTOM_FIELDS_PER_CASE,
fieldName: 'customFields',
});
export const ConfigurationRequestRt = rt.intersection([
rt.strict({
/**
* The external connector
*/
connector: CaseConnectorRt,
/**
* Whether to close the case after it has been synced with the external system
*/
closure_type: ClosureTypeRt,
/**
* The plugin owner that manages this configuration
*/
owner: rt.string,
}),
rt.exact(
rt.partial({
customFields: CustomFieldsConfigurationRt,
})
),
]);
export const GetConfigurationFindRequestRt = rt.exact(
rt.partial({
@ -26,7 +90,13 @@ export const CaseConfigureRequestParamsRt = rt.strict({
});
export const ConfigurationPatchRequestRt = rt.intersection([
rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)),
rt.exact(
rt.partial({
closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type,
connector: ConfigurationBasicWithoutOwnerRt.type.props.connector,
customFields: CustomFieldsConfigurationRt,
})
),
rt.strict({ version: rt.string }),
]);

View file

@ -74,6 +74,18 @@ const basicCase = {
// damaged_raccoon uid
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
category: null,
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: 'toggle',
value: true,
},
],
};
describe('RelatedCaseRt', () => {
@ -170,6 +182,18 @@ describe('CaseAttributesRt', () => {
updated_at: '2020-02-20T15:02:57.995Z',
updated_by: null,
category: null,
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: 'toggle',
value: true,
},
],
};
it('has expected attributes in request', () => {

View file

@ -11,6 +11,7 @@ import { ExternalServiceRt } from '../external_service/v1';
import { CaseAssigneesRt, UserRt } from '../user/v1';
import { CaseConnectorRt } from '../connector/v1';
import { AttachmentRt } from '../attachment/v1';
import { CaseCustomFieldsRt } from '../custom_field/v1';
export { CaseStatuses };
@ -92,6 +93,11 @@ const CaseBasicRt = rt.strict({
* The category of the case.
*/
category: rt.union([rt.string, rt.null]),
/**
* An array containing the possible,
* user-configured custom fields.
*/
customFields: CaseCustomFieldsRt,
});
export const CaseAttributesRt = rt.intersection([

View file

@ -6,7 +6,14 @@
*/
import { ConnectorTypes } from '../connector/v1';
import { ConfigurationAttributesRt, ConfigurationRt } from './v1';
import { CustomFieldTypes } from '../custom_field/v1';
import {
ConfigurationAttributesRt,
ConfigurationRt,
CustomFieldConfigurationWithoutTypeRt,
TextCustomFieldConfigurationRt,
ToggleCustomFieldConfigurationRt,
} from './v1';
describe('configure', () => {
const serviceNow = {
@ -23,10 +30,25 @@ describe('configure', () => {
fields: null,
};
const textCustomField = {
key: 'text_custom_field',
label: 'Text custom field',
type: CustomFieldTypes.TEXT,
required: false,
};
const toggleCustomField = {
key: 'toggle_custom_field',
label: 'Toggle custom field',
type: CustomFieldTypes.TOGGLE,
required: false,
};
describe('ConfigurationAttributesRt', () => {
const defaultRequest = {
connector: resilient,
closure_type: 'close-by-user',
customFields: [textCustomField, toggleCustomField],
owner: 'cases',
created_at: '2020-02-19T23:06:33.798Z',
created_by: {
@ -47,7 +69,10 @@ describe('configure', () => {
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
right: {
...defaultRequest,
customFields: [textCustomField, toggleCustomField],
},
});
});
@ -56,7 +81,25 @@ describe('configure', () => {
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
right: {
...defaultRequest,
customFields: [textCustomField, toggleCustomField],
},
});
});
it('removes foo:bar attributes from custom fields', () => {
const query = ConfigurationAttributesRt.decode({
...defaultRequest,
customFields: [{ ...textCustomField, foo: 'bar' }, toggleCustomField],
});
expect(query).toStrictEqual({
_tag: 'Right',
right: {
...defaultRequest,
customFields: [textCustomField, toggleCustomField],
},
});
});
});
@ -65,6 +108,7 @@ describe('configure', () => {
const defaultRequest = {
connector: serviceNow,
closure_type: 'close-by-user',
customFields: [],
created_at: '2020-02-19T23:06:33.798Z',
created_by: {
full_name: 'Leslie Knope',
@ -116,4 +160,84 @@ describe('configure', () => {
});
});
});
describe('CustomFieldConfigurationWithoutTypeRt', () => {
const defaultRequest = {
key: 'custom_field_key',
label: 'Custom field label',
required: false,
};
it('has expected attributes in request', () => {
const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
});
describe('TextCustomFieldConfigurationRt', () => {
const defaultRequest = {
key: 'my_text_custom_field',
label: 'Text Custom Field',
type: CustomFieldTypes.TEXT,
required: true,
};
it('has expected attributes in request', () => {
const query = TextCustomFieldConfigurationRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
});
describe('ToggleCustomFieldConfigurationRt', () => {
const defaultRequest = {
key: 'my_toggle_custom_field',
label: 'Toggle Custom Field',
type: CustomFieldTypes.TOGGLE,
required: false,
};
it('has expected attributes in request', () => {
const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
it('removes foo:bar attributes from request', () => {
const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: { ...defaultRequest },
});
});
});
});

View file

@ -8,8 +8,44 @@
import * as rt from 'io-ts';
import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1';
import { UserRt } from '../user/v1';
import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1';
const ClosureTypeRt = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]);
export const ClosureTypeRt = rt.union([
rt.literal('close-by-user'),
rt.literal('close-by-pushing'),
]);
export const CustomFieldConfigurationWithoutTypeRt = rt.strict({
/**
* key of custom field
*/
key: rt.string,
/**
* label of custom field
*/
label: rt.string,
/**
* custom field options - required
*/
required: rt.boolean,
});
export const TextCustomFieldConfigurationRt = rt.intersection([
rt.strict({ type: CustomFieldTextTypeRt }),
CustomFieldConfigurationWithoutTypeRt,
]);
export const ToggleCustomFieldConfigurationRt = rt.intersection([
rt.strict({ type: CustomFieldToggleTypeRt }),
CustomFieldConfigurationWithoutTypeRt,
]);
export const CustomFieldConfigurationRt = rt.union([
TextCustomFieldConfigurationRt,
ToggleCustomFieldConfigurationRt,
]);
export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt);
export const ConfigurationBasicWithoutOwnerRt = rt.strict({
/**
@ -20,6 +56,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({
* Whether to close the case after it has been synced with the external system
*/
closure_type: ClosureTypeRt,
/**
* The custom fields configured for the case
*/
customFields: CustomFieldsConfigurationRt,
});
export const CasesConfigureBasicRt = rt.intersection([
@ -57,6 +97,8 @@ export const ConfigurationRt = rt.intersection([
export const ConfigurationsRt = rt.array(ConfigurationRt);
export type CustomFieldsConfiguration = rt.TypeOf<typeof CustomFieldsConfigurationRt>;
export type CustomFieldConfiguration = rt.TypeOf<typeof CustomFieldConfigurationRt>;
export type ClosureType = rt.TypeOf<typeof ClosureTypeRt>;
export type ConfigurationAttributes = rt.TypeOf<typeof ConfigurationAttributesRt>;
export type Configuration = rt.TypeOf<typeof ConfigurationRt>;

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './v1';

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PathReporter } from 'io-ts/lib/PathReporter';
import { CaseCustomFieldRt } from './v1';
describe('CaseCustomFieldRt', () => {
it.each([
[
'type text value text',
{
key: 'string_custom_field_1',
type: 'text',
value: ['this is a text field value'],
},
],
[
'type text value null',
{
key: 'string_custom_field_2',
type: 'text',
value: null,
},
],
[
'type toggle value boolean',
{
key: 'toggle_custom_field_1',
type: 'toggle',
value: true,
},
],
[
'type toggle value null',
{
key: 'toggle_custom_field_2',
type: 'toggle',
value: null,
},
],
])(`has expected attributes for customField with %s`, (_, customField) => {
const query = CaseCustomFieldRt.decode(customField);
expect(query).toStrictEqual({
_tag: 'Right',
right: customField,
});
});
it('fails if text type and value do not match expected attributes in request', () => {
const query = CaseCustomFieldRt.decode({
key: 'text_custom_field_1',
type: 'text',
value: [1],
});
expect(PathReporter.report(query)[0]).toContain('Invalid value 1 supplied');
});
it('fails if toggle type and value do not match expected attributes in request', () => {
const query = CaseCustomFieldRt.decode({
key: 'list_custom_field_1',
type: 'toggle',
value: 'hello',
});
expect(PathReporter.report(query)[0]).toContain('Invalid value "hello" supplied');
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
export enum CustomFieldTypes {
TEXT = 'text',
TOGGLE = 'toggle',
}
export const CustomFieldTextTypeRt = rt.literal(CustomFieldTypes.TEXT);
export const CustomFieldToggleTypeRt = rt.literal(CustomFieldTypes.TOGGLE);
const CaseCustomFieldTextRt = rt.strict({
key: rt.string,
type: CustomFieldTextTypeRt,
value: rt.union([rt.array(rt.string), rt.null]),
});
export const CaseCustomFieldToggleRt = rt.strict({
key: rt.string,
type: CustomFieldToggleTypeRt,
value: rt.union([rt.boolean, rt.null]),
});
export const CaseCustomFieldRt = rt.union([CaseCustomFieldTextRt, CaseCustomFieldToggleRt]);
export const CaseCustomFieldsRt = rt.array(CaseCustomFieldRt);
export type CaseCustomFields = rt.TypeOf<typeof CaseCustomFieldsRt>;
export type CaseCustomField = rt.TypeOf<typeof CaseCustomFieldRt>;
export type CaseCustomFieldToggle = rt.TypeOf<typeof CaseCustomFieldToggleRt>;
export type CaseCustomFieldText = rt.TypeOf<typeof CaseCustomFieldTextRt>;

View file

@ -7,6 +7,7 @@
// Latest
export * from './configure/latest';
export * from './custom_field/latest';
export * from './user_action/latest';
export * from './external_service/latest';
export * from './case/latest';
@ -16,6 +17,7 @@ export * from './attachment/latest';
// V1
export * as configureDomainV1 from './configure/v1';
export * as customFieldDomainV1 from './custom_field/v1';
export * as userActionDomainV1 from './user_action/v1';
export * as externalServiceDomainV1 from './external_service/v1';
export * as caseDomainV1 from './case/v1';

View file

@ -25,6 +25,7 @@ export const UserActionTypes = {
create_case: 'create_case',
delete_case: 'delete_case',
category: 'category',
customFields: 'customFields',
} as const;
type UserActionActionTypeKeys = keyof typeof UserActionTypes;

View file

@ -74,6 +74,33 @@ describe('Create case', () => {
});
});
it('customFields are decoded correctly', () => {
const customFields = [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: 'toggle',
value: true,
},
];
const defaultRequestWithCustomFields = {
...defaultRequest,
payload: { ...defaultRequest.payload, customFields },
};
const query = CreateCaseUserActionRt.decode(defaultRequestWithCustomFields);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequestWithCustomFields,
});
});
it('removes foo:bar attributes from request', () => {
const query = CreateCaseUserActionRt.decode({ ...defaultRequest, foo: 'bar' });
@ -159,6 +186,33 @@ describe('Create case', () => {
});
});
it('customFields are decoded correctly', () => {
const customFields = [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value', 'this is second'],
},
{
key: 'second_custom_field_key',
type: 'toggle',
value: true,
},
];
const defaultRequestWithCustomFields = {
...defaultRequest,
payload: { ...defaultRequest.payload, customFields },
};
const query = CreateCaseUserActionWithoutConnectorIdRt.decode(defaultRequestWithCustomFields);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequestWithCustomFields,
});
});
it('removes foo:bar attributes from request', () => {
const query = CreateCaseUserActionWithoutConnectorIdRt.decode({
...defaultRequest,

View file

@ -13,6 +13,7 @@ import {
ConnectorUserActionPayloadRt,
ConnectorUserActionPayloadWithoutConnectorIdRt,
} from '../connector/v1';
import { CustomFieldsUserActionPayloadRt } from '../custom_fields/v1';
import { DescriptionUserActionPayloadRt } from '../description/v1';
import { SettingsUserActionPayloadRt } from '../settings/v1';
import { TagsUserActionPayloadRt } from '../tags/v1';
@ -36,6 +37,7 @@ const CommonPayloadAttributesRt = rt.strict({
const OptionalPayloadAttributesRt = rt.exact(
rt.partial({
category: CategoryUserActionPayloadRt.type.props.category,
customFields: CustomFieldsUserActionPayloadRt.type.props.customFields,
})
);

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './v1';

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { UserActionTypes } from '../action/v1';
import { CustomFieldsUserActionPayloadRt, CustomFieldsUserActionRt } from './v1';
describe('Custom field', () => {
describe('CustomFieldsUserActionPayloadRt', () => {
const defaultRequest = {
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value'],
},
],
};
it('has expected attributes in request', () => {
const query = CustomFieldsUserActionPayloadRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from request', () => {
const query = CustomFieldsUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
});
describe('CustomFieldsUserActionRt', () => {
const defaultRequest = {
type: UserActionTypes.customFields,
payload: {
customFields: [
{
key: 'first_custom_field_key',
type: 'text',
value: ['this is a text field value'],
},
],
},
};
it('has expected attributes in request', () => {
const query = CustomFieldsUserActionRt.decode(defaultRequest);
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from request', () => {
const query = CustomFieldsUserActionRt.decode({ ...defaultRequest, foo: 'bar' });
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from payload', () => {
const query = CustomFieldsUserActionRt.decode({
...defaultRequest,
payload: { ...defaultRequest.payload, foo: 'bar' },
});
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
it('removes foo:bar attributes from the field', () => {
const query = CustomFieldsUserActionRt.decode({
...defaultRequest,
payload: {
...defaultRequest.payload,
customFields: [{ ...defaultRequest.payload.customFields[0], foo: 'bar' }],
},
});
expect(query).toStrictEqual({
_tag: 'Right',
right: defaultRequest,
});
});
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { CaseCustomFieldsRt } from '../../custom_field/v1';
import { UserActionTypes } from '../action/v1';
export const CustomFieldsUserActionPayloadRt = rt.strict({
customFields: CaseCustomFieldsRt,
});
export const CustomFieldsUserActionRt = rt.strict({
type: rt.literal(UserActionTypes.customFields),
payload: CustomFieldsUserActionPayloadRt,
});

View file

@ -22,6 +22,7 @@ import { SeverityUserActionRt } from './severity/v1';
import { StatusUserActionRt } from './status/v1';
import { TagsUserActionRt } from './tags/v1';
import { TitleUserActionRt } from './title/v1';
import { CustomFieldsUserActionRt } from './custom_fields/v1';
export { UserActionTypes, UserActionActions } from './action/v1';
export { StatusUserActionRt } from './status/v1';
@ -59,6 +60,7 @@ const BasicUserActionsRt = rt.union([
AssigneesUserActionRt,
DeleteCaseUserActionRt,
CategoryUserActionRt,
CustomFieldsUserActionRt,
]);
const CommonUserActionsWithIdsRt = rt.union([BasicUserActionsRt, CommentUserActionRt]);
@ -151,3 +153,4 @@ export type CreateCaseUserAction = UserAction<rt.TypeOf<typeof CreateCaseUserAct
export type CreateCaseUserActionWithoutConnectorId = UserActionWithAttributes<
rt.TypeOf<typeof CreateCaseUserActionWithoutConnectorIdRt>
>;
export type CustomFieldsUserAction = UserAction<rt.TypeOf<typeof CustomFieldsUserActionRt>>;

View file

@ -25,6 +25,7 @@ import type {
Attachment,
ExternalReferenceAttachment,
PersistableStateAttachment,
Configuration,
} from '../types/domain';
import type {
CasePatchRequest,
@ -110,6 +111,7 @@ export type CasesMetrics = SnakeToCamelCase<CasesMetricsResponse>;
export type CaseUpdateRequest = SnakeToCamelCase<CasePatchRequest>;
export type CaseConnectors = SnakeToCamelCase<GetCaseConnectorsResponse>;
export type CaseUsers = GetCaseUsersResponse;
export type CaseUICustomField = CaseUI['customFields'][number];
export interface ResolvedCase {
case: CaseUI;
@ -118,6 +120,13 @@ export interface ResolvedCase {
aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose'];
}
export type CasesConfigurationUI = Pick<
SnakeToCamelCase<Configuration>,
'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version'
>;
export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number];
export type SortOrder = 'asc' | 'desc';
export const SORT_ORDER_VALUES: SortOrder[] = ['asc', 'desc'];
@ -205,6 +214,7 @@ export type UpdateKey = keyof Pick<
| 'severity'
| 'assignees'
| 'category'
| 'customFields'
>;
export interface UpdateByKey {

View file

@ -4579,6 +4579,54 @@
],
"default": "low"
},
"custom_fields_property": {
"type": "array",
"description": "Values for custom fields of a case",
"minItems": 0,
"maxItems": 5,
"items": {
"type": "object",
"required": [
"key",
"type",
"field"
],
"properties": {
"key": {
"description": "The key identifying the custom field.",
"type": "string"
},
"type": {
"description": "The type of the custom field. Should match the key and how the custom field was configured",
"type": "string"
},
"field": {
"description": "An object containing the value of the field.",
"type": "object",
"required": [
"value"
],
"properties": {
"value": {
"description": "The value of the field.",
"nullable": true,
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "boolean"
}
]
}
}
}
}
}
}
},
"create_case_request": {
"title": "Create case request",
"description": "The create case API request body varies depending on the type of connector.",
@ -4652,6 +4700,9 @@
"description": "A title for the case.",
"type": "string",
"maxLength": 160
},
"customFields": {
"$ref": "#/components/schemas/custom_fields_property"
}
}
},
@ -5296,6 +5347,9 @@
"version": {
"description": "The current version of the case. To determine this value, use the get case or find cases APIs.",
"type": "string"
},
"customFields": {
"$ref": "#/components/schemas/custom_fields_property"
}
}
}

View file

@ -2890,6 +2890,38 @@ components:
- low
- medium
default: low
custom_fields_property:
type: array
description: Values for custom fields of a case
minItems: 0
maxItems: 5
items:
type: object
required:
- key
- type
- field
properties:
key:
description: The key identifying the custom field.
type: string
type:
description: The type of the custom field. Should match the key and how the custom field was configured
type: string
field:
description: An object containing the value of the field.
type: object
required:
- value
properties:
value:
description: The value of the field.
nullable: true
type: array
items:
anyOf:
- type: string
- type: boolean
create_case_request:
title: Create case request
description: The create case API request body varies depending on the type of connector.
@ -2938,6 +2970,8 @@ components:
description: A title for the case.
type: string
maxLength: 160
customFields:
$ref: '#/components/schemas/custom_fields_property'
case_response_closed_by_properties:
title: Case response properties for closed_by
type: object
@ -3403,6 +3437,8 @@ components:
version:
description: The current version of the case. To determine this value, use the get case or find cases APIs.
type: string
customFields:
$ref: '#/components/schemas/custom_fields_property'
searchFieldsType:
type: string
description: The fields to perform the `simple_query_string` parsed query against.

View file

@ -3,12 +3,12 @@ description: >-
The create case API request body varies depending on the type of connector.
type: object
required:
- connector
- description
- owner
- settings
- tags
- title
- connector
- description
- owner
- settings
- tags
- title
properties:
assignees:
$ref: 'assignees.yaml'
@ -45,4 +45,6 @@ properties:
title:
description: A title for the case.
type: string
maxLength: 160
maxLength: 160
customFields:
$ref: 'custom_fields_property.yaml'

View file

@ -0,0 +1,31 @@
type: array
description: Values for custom fields of a case
minItems: 0
maxItems: 5
items:
type: object
required:
- key
- type
- field
properties:
key:
description: The key identifying the custom field.
type: string
type:
description: The type of the custom field. Should match the key and how the custom field was configured
type: string
field:
description: An object containing the value of the field.
type: object
required:
- value
properties:
value:
description: The value of the field.
nullable: true
type: array
items:
anyOf:
- type: string
- type: boolean

View file

@ -58,3 +58,5 @@ properties:
version:
description: The current version of the case. To determine this value, use the get case or find cases APIs.
type: string
customFields:
$ref: 'custom_fields_property.yaml'

View file

@ -192,12 +192,8 @@ export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldR
defaultMessage: 'A name is required.',
});
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', {
defaultMessage: 'Configure cases',
});
export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', {
defaultMessage: 'Edit external connection',
defaultMessage: 'Settings',
});
export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', {
@ -380,3 +376,12 @@ export const ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_TAG_CUSTOM_OPTION_LABEL
export const ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX =
ADD_CATEGORY_CUSTOM_OPTION_LABEL('{searchValue}');
export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.badge.experimentalLabel', {
defaultMessage: 'Technical preview',
});
export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.badge.experimentalDesc', {
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
});

View file

@ -138,46 +138,6 @@ describe('AllCases', () => {
});
});
it('should not allow the user to enter configuration page with basic license', async () => {
useGetActionLicenseMock.mockReturnValue({
...defaultActionLicense,
data: {
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: false,
},
});
const result = appMockRender.render(<AllCases />);
await waitFor(() => {
expect(result.getByTestId('configure-case-button')).toBeDisabled();
});
});
it('should allow the user to enter configuration page with gold license and above', async () => {
useGetActionLicenseMock.mockReturnValue({
...defaultActionLicense,
data: {
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
});
const result = appMockRender.render(<AllCases />);
await waitFor(() => {
expect(result.getByTestId('configure-case-button')).not.toBeDisabled();
});
});
it('should render the case callouts', async () => {
const result = appMockRender.render(<AllCases />);
await waitFor(() => {

View file

@ -54,7 +54,6 @@ export const NavButtons: FunctionComponent<Props> = ({ actionsErrors }) => {
<EuiFlexItem grow={false}>
<ConfigureCaseButton
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? <>{actionsErrors[0].description}</> : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}

View file

@ -10,13 +10,14 @@
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { isEqual } from 'lodash';
import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration';
import { useGetCaseUsers } from '../../../containers/use_get_case_users';
import { useGetCaseConnectors } from '../../../containers/use_get_case_connectors';
import { useCasesFeatures } from '../../../common/use_cases_features';
import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile';
import { useGetSupportedActionConnectors } from '../../../containers/configure/use_get_supported_action_connectors';
import type { CaseSeverity, CaseStatuses } from '../../../../common/types/domain';
import type { UseFetchAlertData } from '../../../../common/ui/types';
import type { CaseUICustomField, UseFetchAlertData } from '../../../../common/ui/types';
import type { CaseUI } from '../../../../common';
import { EditConnector } from '../../edit_connector';
import type { CasesNavigation } from '../../links';
@ -38,6 +39,7 @@ import { CaseViewTabs } from '../case_view_tabs';
import { Description } from '../../description';
import { EditCategory } from './edit_category';
import { parseCaseUsers } from '../../utils';
import { CustomFields } from './custom_fields';
export const CaseViewActivity = ({
ruleDetailsNavigation,
@ -72,6 +74,8 @@ export const CaseViewActivity = ({
const { data: caseUsers, isLoading: isLoadingCaseUsers } = useGetCaseUsers(caseData.id);
const { data: casesConfiguration } = useGetCaseConfiguration();
const { userProfiles, reporterAsArray } = parseCaseUsers({
caseUsers,
createdBy: caseData.createdBy,
@ -148,6 +152,16 @@ export const CaseViewActivity = ({
[onUpdateField]
);
const onSubmitCustomFields = useCallback(
(customFields: CaseUICustomField[]) => {
onUpdateField({
key: 'customFields',
value: customFields,
});
},
[onUpdateField]
);
const handleUserActionsActivityChanged = useCallback(
(params: UserActivityParams) => {
setUserActivityQueryParams((oldParams) => ({
@ -205,6 +219,7 @@ export const CaseViewActivity = ({
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseConnectors={caseConnectors}
data={caseData}
casesConfiguration={casesConfiguration}
actionsNavigation={actionsNavigation}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
@ -283,6 +298,12 @@ export const CaseViewActivity = ({
key={caseData.connector.id}
/>
) : null}
<CustomFields
isLoading={isLoading && loadingKey === 'customFields'}
customFields={caseData.customFields}
customFieldsConfiguration={casesConfiguration.customFields}
onSubmit={onSubmitCustomFields}
/>
</EuiFlexGroup>
</EuiFlexItem>
</>

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import type { AppMockRenderer } from '../../../common/mock';
import { readCasesPermissions, createAppMockRenderer } from '../../../common/mock';
import { CustomFields } from './custom_fields';
import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock';
import userEvent from '@testing-library/user-event';
import { CustomFieldTypes } from '../../../../common/types/domain';
describe('Case View Page files tab', () => {
const onSubmit = jest.fn();
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render the custom fields correctly', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={customFieldsMock}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument();
expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument();
});
it('should render the custom fields types when the custom fields are empty', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={[]}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument();
expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument();
});
it('should not show the custom fields if the configuration is empty', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={customFieldsMock}
customFieldsConfiguration={[]}
onSubmit={onSubmit}
/>
);
expect(screen.queryByTestId('case-custom-field-wrapper-test_key_1')).not.toBeInTheDocument();
expect(screen.queryByTestId('case-custom-field-wrapper-test_key_2')).not.toBeInTheDocument();
});
it('should sort the custom fields correctly', async () => {
const reversedConfiguration = [...customFieldsConfigurationMock].reverse();
appMockRender.render(
<CustomFields
isLoading={false}
customFields={[]}
customFieldsConfiguration={reversedConfiguration}
onSubmit={onSubmit}
/>
);
const customFields = screen.getAllByTestId('case-custom-field-wrapper', { exact: false });
expect(customFields.length).toBe(2);
expect(within(customFields[0]).getByRole('heading')).toHaveTextContent('My test label 1');
expect(within(customFields[1]).getByRole('heading')).toHaveTextContent('My test label 2');
});
it('pass the permissions to custom fields correctly', async () => {
appMockRender = createAppMockRenderer({ permissions: readCasesPermissions() });
appMockRender.render(
<CustomFields
isLoading={false}
customFields={customFieldsMock}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
expect(
screen.queryByTestId('case-text-custom-field-edit-button-test_key_1')
).not.toBeInTheDocument();
});
it('adds missing custom fields with no custom fields in the case', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={[]}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith([
{
type: CustomFieldTypes.TEXT,
key: 'test_key_1',
value: null,
},
{ type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true },
]);
});
});
it('adds missing custom fields with some custom fields in the case', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={[{ type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }]}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith([
{
type: CustomFieldTypes.TEXT,
key: 'test_key_1',
value: null,
},
{ type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false },
]);
});
});
it('removes extra custom fields', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={customFieldsMock}
customFieldsConfiguration={[
{
type: CustomFieldTypes.TOGGLE,
key: 'test_key_2',
label: 'My test label 2',
required: false,
},
]}
onSubmit={onSubmit}
/>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith([
{ type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false },
]);
});
});
it('updates an existing field correctly', async () => {
appMockRender.render(
<CustomFields
isLoading={false}
customFields={customFieldsMock}
customFieldsConfiguration={customFieldsConfigurationMock}
onSubmit={onSubmit}
/>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith([
customFieldsMock[0],
{ type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false },
]);
});
});
});

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 React, { useCallback, useMemo } from 'react';
import { sortBy } from 'lodash';
import { EuiFlexItem } from '@elastic/eui';
import type {
CasesConfigurationUI,
CasesConfigurationUICustomField,
CaseUICustomField,
} from '../../../../common/ui';
import type { CaseUI } from '../../../../common';
import { useCasesContext } from '../../cases_context/use_cases_context';
import { builderMap as customFieldsBuilderMap } from '../../custom_fields/builder';
import { addOrReplaceCustomField } from '../../custom_fields/utils';
interface Props {
isLoading: boolean;
customFields: CaseUI['customFields'];
customFieldsConfiguration: CasesConfigurationUI['customFields'];
onSubmit: (customFields: CaseUICustomField[]) => void;
}
const CustomFieldsComponent: React.FC<Props> = ({
isLoading,
customFields,
customFieldsConfiguration,
onSubmit,
}) => {
const { permissions } = useCasesContext();
const sortedCustomFieldsConfiguration = useMemo(
() => sortCustomFieldsByLabel(customFieldsConfiguration),
[customFieldsConfiguration]
);
const onSubmitCustomField = useCallback(
(customFieldToAdd) => {
const allCustomFields = createMissingAndRemoveExtraCustomFields(
customFields,
customFieldsConfiguration
);
const updatedCustomFields = addOrReplaceCustomField(allCustomFields, customFieldToAdd);
onSubmit(updatedCustomFields);
},
[customFields, customFieldsConfiguration, onSubmit]
);
const customFieldsComponents = sortedCustomFieldsConfiguration.map((customFieldConf) => {
const customFieldFactory = customFieldsBuilderMap[customFieldConf.type];
const customFieldType = customFieldFactory().build();
const customField = customFields.find((field) => field.key === customFieldConf.key);
const EditComponent = customFieldType.Edit;
return (
<EuiFlexItem
grow={false}
data-test-subj={`case-custom-field-wrapper-${customFieldConf.key}`}
key={customFieldConf.key}
>
<EditComponent
isLoading={isLoading}
canUpdate={permissions.update}
customFieldConfiguration={customFieldConf}
customField={customField}
onSubmit={onSubmitCustomField}
/>
</EuiFlexItem>
);
});
return <>{customFieldsComponents}</>;
};
CustomFieldsComponent.displayName = 'CustomFields';
export const CustomFields = React.memo(CustomFieldsComponent);
const sortCustomFieldsByLabel = (customFieldsConfiguration: Props['customFieldsConfiguration']) => {
return sortBy(customFieldsConfiguration, (customFieldConf) => {
return customFieldConf.label;
});
};
const createMissingAndRemoveExtraCustomFields = (
customFields: CaseUICustomField[],
confCustomFields: CasesConfigurationUICustomField[]
): CaseUICustomField[] => {
const createdCustomFields: CaseUICustomField[] = confCustomFields.map((confCustomField) => {
const foundCustomField = customFields.find(
(customField) => customField.key === confCustomField.key
);
if (foundCustomField) {
return foundCustomField;
}
return { key: confCustomField.key, type: confCustomField.type, value: null };
});
return createdCustomFields;
};

View file

@ -49,6 +49,12 @@ export const REMOVED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.re
defaultMessage: 'removed',
});
export const CHANGED_FIELD_TO_EMPTY = (field: string) =>
i18n.translate('xpack.cases.caseView.actionLabel.changeFieldToEmpty', {
values: { field },
defaultMessage: 'changed {field} to "None"',
});
export const VIEW_INCIDENT = (incidentNumber: string) =>
i18n.translate('xpack.cases.caseView.actionLabel.viewIncident', {
defaultMessage: 'View {incidentNumber}',

View file

@ -93,6 +93,12 @@ export const useOnUpdateField = ({ caseData }: { caseData: CaseUI }) => {
callUpdate('assignees', assigneesUpdate);
}
break;
case 'customFields':
const customFields = getTypedPayload<CaseAttributes['customFields']>(value);
if (!deepEqual(caseData.customFields, value)) {
callUpdate('customFields', customFields);
}
break;
default:
return null;
}

View file

@ -8,7 +8,6 @@
import type { ActionTypeConnector } from '../../../../common/types/domain';
import { ConnectorTypes } from '../../../../common/types/domain';
import type { ActionConnector } from '../../../containers/configure/types';
import type { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure';
import { connectorsMock, actionTypesMock } from '../../../common/mock/connectors';
export { mappings } from '../../../containers/configure/mock';
@ -18,35 +17,28 @@ export const actionTypes: ActionTypeConnector[] = actionTypesMock;
export const searchURL =
'?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))';
export const useCaseConfigureResponse: ReturnUseCaseConfigure = {
closureType: 'close-by-user',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
currentConfiguration: {
export const useCaseConfigureResponse = {
data: {
closureType: 'close-by-user',
connector: {
fields: null,
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
customFields: [],
mappings: [],
version: '',
id: '',
},
firstLoad: false,
loading: false,
mappings: [],
persistCaseConfigure: jest.fn(),
persistLoading: false,
refetchCaseConfigure: jest.fn(),
setClosureType: jest.fn(),
setConnector: jest.fn(),
setCurrentConfiguration: jest.fn(),
setMappings: jest.fn(),
version: '',
id: '',
isLoading: false,
refetch: jest.fn(),
};
export const usePersistConfigurationMockResponse = {
isLoading: false,
mutate: jest.fn(),
mutateAsync: jest.fn(),
};
export const useConnectorsResponse = {

View file

@ -8,15 +8,19 @@
import React from 'react';
import type { ReactWrapper } from 'enzyme';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfigureCases } from '.';
import { noUpdateCasesPermissions, TestProviders } from '../../common/mock';
import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock';
import { customFieldsConfigurationMock } from '../../containers/mock';
import type { AppMockRenderer } from '../../common/mock';
import { Connectors } from './connectors';
import { ClosureOptions } from './closure_options';
import { useKibana } from '../../common/lib/kibana';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration';
import {
connectors,
@ -24,24 +28,31 @@ import {
useCaseConfigureResponse,
useConnectorsResponse,
useActionTypesResponse,
usePersistConfigurationMockResponse,
} from './__mock__';
import { ConnectorTypes } from '../../../common/types/domain';
import type { CustomFieldsConfiguration } from '../../../common/types/domain';
import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
import { useGetActionTypes } from '../../containers/configure/use_action_types';
import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors';
import { useLicense } from '../../common/use_license';
jest.mock('../../common/lib/kibana');
jest.mock('../../containers/configure/use_get_supported_action_connectors');
jest.mock('../../containers/configure/use_configure');
jest.mock('../../containers/configure/use_get_case_configuration');
jest.mock('../../containers/configure/use_persist_configuration');
jest.mock('../../containers/configure/use_action_types');
jest.mock('../../common/use_license');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock;
const usePersistConfigurationMock = usePersistConfiguration as jest.Mock;
const useGetUrlSearchMock = jest.fn();
const useGetActionTypesMock = useGetActionTypes as jest.Mock;
const getAddConnectorFlyoutMock = jest.fn();
const getEditConnectorFlyoutMock = jest.fn();
const useLicenseMock = useLicense as jest.Mock;
describe('ConfigureCases', () => {
beforeAll(() => {
@ -59,12 +70,14 @@ describe('ConfigureCases', () => {
beforeEach(() => {
useGetActionTypesMock.mockImplementation(() => useActionTypesResponse);
useLicenseMock.mockReturnValue({ isAtLeastGold: () => true });
});
describe('rendering', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse);
useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] }));
useGetUrlSearchMock.mockImplementation(() => searchURL);
@ -100,25 +113,19 @@ describe('ConfigureCases', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
...useCaseConfigureResponse,
closureType: 'close-by-user',
connector: {
id: 'not-id',
name: 'unchanged',
type: ConnectorTypes.none,
fields: null,
},
currentConfiguration: {
useGetCaseConfigurationMock.mockImplementation(() => ({
data: {
...useCaseConfigureResponse.data,
closureType: 'close-by-user',
connector: {
id: 'not-id',
name: 'unchanged',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
},
}));
useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] }));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases />, {
@ -145,26 +152,21 @@ describe('ConfigureCases', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
mappings: [],
closureType: 'close-by-user',
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
currentConfiguration: {
data: {
...useCaseConfigureResponse.data,
mappings: [],
closureType: 'close-by-user',
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-user',
},
}));
useGetConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
@ -226,24 +228,18 @@ describe('ConfigureCases', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
data: {
...useCaseConfigureResponse.data,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'servicenow-1',
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.serviceNowITSM,
type: ConnectorTypes.resilient,
fields: null,
},
closureType: 'close-by-user',
},
}));
@ -302,15 +298,22 @@ describe('ConfigureCases', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
data: {
...useCaseConfigureResponse.data,
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
},
persistLoading: true,
}));
usePersistConfigurationMock.mockImplementation(() => ({
...usePersistConfigurationMockResponse,
isLoading: true,
}));
useGetConnectorsMock.mockImplementation(() => useConnectorsResponse);
@ -350,13 +353,15 @@ describe('ConfigureCases', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
loading: true,
isLoading: true,
}));
useGetConnectorsMock.mockImplementation(() => ({
...useConnectorsResponse,
}));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases />, {
wrappingComponent: TestProviders,
@ -373,32 +378,29 @@ describe('ConfigureCases', () => {
});
describe('connectors', () => {
const persistCaseConfigure = jest.fn();
let wrapper: ReactWrapper;
let persistCaseConfigure: jest.Mock;
beforeEach(() => {
persistCaseConfigure = jest.fn();
useCaseConfigureMock.mockImplementation(() => ({
...useCaseConfigureResponse,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'resilient-2',
name: 'My connector',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
useGetCaseConfigurationMock.mockImplementation(() => ({
data: {
...useCaseConfigureResponse.data,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'My connector',
id: 'resilient-2',
name: 'My connector',
type: ConnectorTypes.jira,
type: ConnectorTypes.resilient,
fields: null,
},
closureType: 'close-by-user',
},
persistCaseConfigure,
}));
usePersistConfigurationMock.mockImplementation(() => ({
...usePersistConfigurationMockResponse,
mutate: persistCaseConfigure,
}));
useGetConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
@ -422,27 +424,36 @@ describe('ConfigureCases', () => {
fields: null,
},
closureType: 'close-by-user',
customFields: [],
id: '',
version: '',
});
});
test('the text of the update button is changed successfully', () => {
useCaseConfigureMock
useGetCaseConfigurationMock
.mockImplementationOnce(() => ({
...useCaseConfigureResponse,
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.serviceNowITSM,
fields: null,
data: {
...useCaseConfigureResponse.data,
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
},
}))
.mockImplementation(() => ({
...useCaseConfigureResponse,
connector: {
id: 'resilient-2',
name: 'My Resilient connector',
type: ConnectorTypes.resilient,
fields: null,
data: {
...useCaseConfigureResponse.data,
connector: {
id: 'resilient-2',
name: 'My Resilient connector',
type: ConnectorTypes.resilient,
fields: null,
},
},
}));
@ -469,26 +480,24 @@ describe('ConfigureCases', () => {
beforeEach(() => {
persistCaseConfigure = jest.fn();
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
currentConfiguration: {
data: {
...useCaseConfigureResponse.data,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'My connector',
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.jira,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-user',
},
persistCaseConfigure,
}));
usePersistConfigurationMock.mockImplementation(() => ({
...usePersistConfigurationMockResponse,
mutate: persistCaseConfigure,
}));
useGetConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
@ -511,32 +520,31 @@ describe('ConfigureCases', () => {
fields: null,
},
closureType: 'close-by-pushing',
customFields: [],
id: '',
version: '',
});
});
});
describe('user interactions', () => {
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
data: {
...useCaseConfigureResponse.data,
mapping: null,
closureType: 'close-by-user',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.serviceNowITSM,
type: ConnectorTypes.resilient,
fields: null,
},
closureType: 'close-by-user',
},
}));
useGetConnectorsMock.mockImplementation(() => useConnectorsResponse);
useGetUrlSearchMock.mockImplementation(() => searchURL);
});
@ -597,4 +605,267 @@ describe('ConfigureCases', () => {
).toBeFalsy();
});
});
describe('custom fields', () => {
let appMockRender: AppMockRenderer;
let persistCaseConfigure: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
persistCaseConfigure = jest.fn();
usePersistConfigurationMock.mockImplementation(() => ({
...usePersistConfigurationMockResponse,
mutate: persistCaseConfigure,
}));
});
it('renders custom field group when no custom fields available', () => {
appMockRender.render(<ConfigureCases />);
expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument();
});
it('renders custom field when available', () => {
const customFieldsMock: CustomFieldsConfiguration = [
{
key: 'random_custom_key',
label: 'summary',
type: CustomFieldTypes.TEXT,
required: true,
},
];
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
customFields: customFieldsMock,
},
}));
appMockRender.render(<ConfigureCases />);
expect(
screen.getByTestId(`custom-field-${customFieldsMock[0].label}-${customFieldsMock[0].type}`)
).toBeInTheDocument();
});
it('renders multiple custom field when available', () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
customFields: customFieldsConfigurationMock,
},
}));
appMockRender.render(<ConfigureCases />);
const list = screen.getByTestId('custom-fields-list');
for (const field of customFieldsConfigurationMock) {
expect(
within(list).getByTestId(`custom-field-${field.label}-${field.type}`)
).toBeInTheDocument();
}
});
it('deletes a custom field correctly', async () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
customFields: customFieldsConfigurationMock,
},
}));
appMockRender.render(<ConfigureCases />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
userEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(persistCaseConfigure).toHaveBeenCalledWith({
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
customFields: [{ ...customFieldsConfigurationMock[1] }],
id: '',
version: '',
});
});
});
it('updates a custom field correctly', async () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
customFields: customFieldsConfigurationMock,
},
}));
appMockRender.render(<ConfigureCases />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
);
expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument();
userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!');
userEvent.click(screen.getByTestId('text-custom-field-options'));
userEvent.click(screen.getByTestId('custom-field-flyout-save'));
await waitFor(() => {
expect(persistCaseConfigure).toHaveBeenCalledWith({
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
customFields: [
{
...customFieldsConfigurationMock[0],
label: `${customFieldsConfigurationMock[0].label}!!`,
required: !customFieldsConfigurationMock[0].required,
},
{ ...customFieldsConfigurationMock[1] },
],
id: '',
version: '',
});
});
});
it('opens fly out for when click on add field', async () => {
appMockRender.render(<ConfigureCases />);
userEvent.click(screen.getByTestId('add-custom-field'));
expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument();
});
it('closes fly out for when click on cancel', async () => {
appMockRender.render(<ConfigureCases />);
userEvent.click(screen.getByTestId('add-custom-field'));
expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument();
userEvent.click(screen.getByTestId('custom-field-flyout-cancel'));
expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument();
expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument();
});
it('closes fly out for when click on save field', async () => {
appMockRender.render(<ConfigureCases />);
userEvent.click(screen.getByTestId('add-custom-field'));
expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument();
userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary');
userEvent.click(screen.getByTestId('custom-field-flyout-save'));
await waitFor(() => {
expect(persistCaseConfigure).toHaveBeenCalledWith({
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
customFields: [
...customFieldsConfigurationMock,
{
key: expect.anything(),
label: 'Summary',
type: CustomFieldTypes.TEXT,
required: false,
},
],
id: '',
version: '',
});
});
expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument();
expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument();
});
});
describe('rendering with license limitations', () => {
let appMockRender: AppMockRenderer;
let persistCaseConfigure: jest.Mock;
beforeEach(() => {
// Default setup
jest.clearAllMocks();
useGetConnectorsMock.mockImplementation(() => ({ useConnectorsResponse }));
appMockRender = createAppMockRenderer();
persistCaseConfigure = jest.fn();
usePersistConfigurationMock.mockImplementation(() => ({
...usePersistConfigurationMockResponse,
mutate: persistCaseConfigure,
}));
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
// Updated
useLicenseMock.mockReturnValue({ isAtLeastGold: () => false });
});
it('should not render connectors and closure options', () => {
appMockRender.render(<ConfigureCases />);
expect(screen.queryByTestId('dropdown-connectors')).not.toBeInTheDocument();
expect(screen.queryByTestId('closure-options-radio-group')).not.toBeInTheDocument();
});
it('should render custom field section', () => {
appMockRender.render(<ConfigureCases />);
expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument();
});
describe('when the previously selected connector doesnt appear due to license downgrade or because it was deleted', () => {
beforeEach(() => {
useGetCaseConfigurationMock.mockImplementation(() => ({
data: {
...useCaseConfigureResponse.data,
closureType: 'close-by-user',
connector: {
id: 'not-id',
name: 'unchanged',
type: ConnectorTypes.none,
fields: null,
},
},
}));
});
it('should not render the warning callout', () => {
expect(screen.queryByTestId('configure-cases-warning-callout')).not.toBeInTheDocument();
});
});
});
});

View file

@ -9,13 +9,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled, { css } from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiLink, EuiPageBody } from '@elastic/eui';
import { EuiCallOut, EuiFlexItem, EuiLink, EuiPageBody } from '@elastic/eui';
import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types';
import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common';
import type { CustomFieldConfiguration } from '../../../common/types/domain';
import { useKibana } from '../../common/lib/kibana';
import { useGetActionTypes } from '../../containers/configure/use_action_types';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import type { ClosureType } from '../../containers/configure/types';
@ -29,7 +30,12 @@ import { HeaderPage } from '../header_page';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCasesBreadcrumbs } from '../use_breadcrumbs';
import { CasesDeepLinkId } from '../../common/navigation';
import { CustomFields } from '../custom_fields';
import { CustomFieldFlyout } from '../custom_fields/flyout';
import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors';
import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration';
import { addOrReplaceCustomField } from '../custom_fields/utils';
import { useLicense } from '../../common/use_license';
const FormWrapper = styled.div`
${({ theme }) => css`
@ -53,6 +59,8 @@ export const ConfigureCases: React.FC = React.memo(() => {
const { permissions } = useCasesContext();
const { triggersActionsUi } = useKibana().services;
useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure);
const license = useLicense();
const hasMinimumLicensePermissions = license.isAtLeastGold();
const [connectorIsValid, setConnectorIsValid] = useState(true);
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
@ -60,18 +68,29 @@ export const ConfigureCases: React.FC = React.memo(() => {
const [editedConnectorItem, setEditedConnectorItem] = useState<ActionConnectorTableItem | null>(
null
);
const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState<boolean>(false);
const [customFieldToEdit, setCustomFieldToEdit] = useState<CustomFieldConfiguration | null>(null);
const {
connector,
closureType,
loading: loadingCaseConfigure,
mappings,
persistLoading,
persistCaseConfigure,
refetchCaseConfigure,
setConnector,
setClosureType,
} = useCaseConfigure();
data: {
id: configurationId,
version: configurationVersion,
closureType,
connector,
mappings,
customFields,
},
isLoading: loadingCaseConfigure,
refetch: refetchCaseConfigure,
} = useGetCaseConfiguration();
const {
mutate: persistCaseConfigure,
mutateAsync: persistCaseConfigureAsync,
isLoading: isPersistingConfiguration,
} = usePersistConfiguration();
const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration;
const {
isLoading: isLoadingConnectors,
@ -98,18 +117,31 @@ export const ConfigureCases: React.FC = React.memo(() => {
async (createdConnector) => {
const caseConnector = normalizeActionConnector(createdConnector);
await persistCaseConfigure({
await persistCaseConfigureAsync({
connector: caseConnector,
closureType,
customFields,
id: configurationId,
version: configurationVersion,
});
onConnectorUpdated(createdConnector);
setConnector(caseConnector);
},
[onConnectorUpdated, closureType, setConnector, persistCaseConfigure]
[
persistCaseConfigureAsync,
closureType,
customFields,
configurationId,
configurationVersion,
onConnectorUpdated,
]
);
const isLoadingAny =
isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes;
isLoadingConnectors ||
isPersistingConfiguration ||
loadingCaseConfigure ||
isLoadingActionTypes;
const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none';
const onClickUpdateConnector = useCallback(() => {
setEditFlyoutVisibility(true);
@ -133,24 +165,35 @@ export const ConfigureCases: React.FC = React.memo(() => {
const caseConnector =
actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector();
setConnector(caseConnector);
persistCaseConfigure({
connector: caseConnector,
closureType,
customFields,
id: configurationId,
version: configurationVersion,
});
},
[connectors, closureType, persistCaseConfigure, setConnector]
[
connectors,
persistCaseConfigure,
closureType,
customFields,
configurationId,
configurationVersion,
]
);
const onChangeClosureType = useCallback(
(type: ClosureType) => {
setClosureType(type);
persistCaseConfigure({
connector,
customFields,
id: configurationId,
version: configurationVersion,
closureType: type,
});
},
[connector, persistCaseConfigure, setClosureType]
[configurationId, configurationVersion, connector, customFields, persistCaseConfigure]
);
useEffect(() => {
@ -202,6 +245,88 @@ export const ConfigureCases: React.FC = React.memo(() => {
[connector.id, editedConnectorItem, editFlyoutVisible]
);
const onAddCustomFields = useCallback(() => {
setCustomFieldFlyoutVisibility(true);
}, [setCustomFieldFlyoutVisibility]);
const onDeleteCustomField = useCallback(
(key: string) => {
const remainingCustomFields = customFields.filter((field) => field.key !== key);
persistCaseConfigure({
connector,
customFields: [...remainingCustomFields],
id: configurationId,
version: configurationVersion,
closureType,
});
},
[
closureType,
configurationId,
configurationVersion,
connector,
customFields,
persistCaseConfigure,
]
);
const onEditCustomField = useCallback(
(key: string) => {
const selectedCustomField = customFields.find((item) => item.key === key);
if (selectedCustomField) {
setCustomFieldToEdit(selectedCustomField);
}
setCustomFieldFlyoutVisibility(true);
},
[setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields]
);
const onCloseAddFieldFlyout = useCallback(() => {
setCustomFieldFlyoutVisibility(false);
setCustomFieldToEdit(null);
}, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]);
const onSaveCustomField = useCallback(
(customFieldData: CustomFieldConfiguration) => {
const updatedFields = addOrReplaceCustomField(customFields, customFieldData);
persistCaseConfigure({
connector,
customFields: updatedFields,
id: configurationId,
version: configurationVersion,
closureType,
});
setCustomFieldFlyoutVisibility(false);
setCustomFieldToEdit(null);
},
[
closureType,
configurationId,
configurationVersion,
connector,
customFields,
persistCaseConfigure,
]
);
const CustomFieldAddFlyout = customFieldFlyoutVisible ? (
<CustomFieldFlyout
isLoading={loadingCaseConfigure || isPersistingConfiguration}
disabled={
!permissions.create ||
!permissions.update ||
loadingCaseConfigure ||
isPersistingConfiguration
}
customField={customFieldToEdit}
onCloseFlyout={onCloseAddFieldFlyout}
onSaveField={onSaveCustomField}
/>
) : null;
return (
<>
<HeaderPage
@ -212,50 +337,67 @@ export const ConfigureCases: React.FC = React.memo(() => {
/>
<EuiPageBody restrictWidth={true}>
<FormWrapper>
{!connectorIsValid && (
<SectionWrapper style={{ marginTop: 0 }}>
<EuiCallOut
title={i18n.WARNING_NO_CONNECTOR_TITLE}
color="warning"
iconType="help"
data-test-subj="configure-cases-warning-callout"
>
<FormattedMessage
defaultMessage="The selected connector has been deleted or you do not have the {appropriateLicense} to use it. Either select a different connector or create a new one."
id="xpack.cases.configure.connectorDeletedOrLicenseWarning"
values={{
appropriateLicense: (
<EuiLink href="https://www.elastic.co/subscriptions" target="_blank">
{i18n.LINK_APPROPRIATE_LICENSE}
</EuiLink>
),
}}
{hasMinimumLicensePermissions && (
<>
{!connectorIsValid && (
<SectionWrapper style={{ marginTop: 0 }}>
<EuiCallOut
title={i18n.WARNING_NO_CONNECTOR_TITLE}
color="warning"
iconType="help"
data-test-subj="configure-cases-warning-callout"
>
<FormattedMessage
defaultMessage="The selected connector has been deleted or you do not have the {appropriateLicense} to use it. Either select a different connector or create a new one."
id="xpack.cases.configure.connectorDeletedOrLicenseWarning"
values={{
appropriateLicense: (
<EuiLink href="https://www.elastic.co/subscriptions" target="_blank">
{i18n.LINK_APPROPRIATE_LICENSE}
</EuiLink>
),
}}
/>
</EuiCallOut>
</SectionWrapper>
)}
<SectionWrapper>
<ClosureOptions
closureTypeSelected={closureType}
disabled={isPersistingConfiguration || isLoadingConnectors || !permissions.update}
onChangeClosureType={onChangeClosureType}
/>
</EuiCallOut>
</SectionWrapper>
</SectionWrapper>
<SectionWrapper>
<Connectors
actionTypes={actionTypes}
connectors={connectors ?? []}
disabled={isPersistingConfiguration || isLoadingConnectors || !permissions.update}
handleShowEditFlyout={onClickUpdateConnector}
isLoading={isLoadingAny}
mappings={mappings}
onChangeConnector={onChangeConnector}
selectedConnector={connector}
updateConnectorDisabled={updateConnectorDisabled || !permissions.update}
/>
</SectionWrapper>
</>
)}
<SectionWrapper>
<ClosureOptions
closureTypeSelected={closureType}
disabled={persistLoading || isLoadingConnectors || !permissions.update}
onChangeClosureType={onChangeClosureType}
/>
</SectionWrapper>
<SectionWrapper>
<Connectors
actionTypes={actionTypes}
connectors={connectors ?? []}
disabled={persistLoading || isLoadingConnectors || !permissions.update}
handleShowEditFlyout={onClickUpdateConnector}
isLoading={isLoadingAny}
mappings={mappings}
onChangeConnector={onChangeConnector}
selectedConnector={connector}
updateConnectorDisabled={updateConnectorDisabled || !permissions.update}
/>
<EuiFlexItem grow={false}>
<CustomFields
customFields={customFields}
isLoading={isLoadingCaseConfiguration}
disabled={isLoadingCaseConfiguration}
handleAddCustomField={onAddCustomFields}
handleDeleteCustomField={onDeleteCustomField}
handleEditCustomField={onEditCustomField}
/>
</EuiFlexItem>
</SectionWrapper>
{ConnectorAddFlyout}
{ConnectorEditFlyout}
{CustomFieldAddFlyout}
</FormWrapper>
</EuiPageBody>
</>

View file

@ -150,7 +150,7 @@ export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate(
);
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', {
defaultMessage: 'Configure cases',
defaultMessage: 'Settings',
});
export const CASES_WEBHOOK_MAPPINGS = i18n.translate(

View file

@ -27,18 +27,18 @@ import {
createAppMockRenderer,
TestProviders,
} from '../../common/mock';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
jest.mock('../connectors/resilient/use_get_incident_types');
jest.mock('../connectors/resilient/use_get_severity');
jest.mock('../connectors/servicenow/use_get_choices');
jest.mock('../../containers/configure/use_configure');
jest.mock('../../containers/configure/use_get_case_configuration');
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const useGetChoicesMock = useGetChoices as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock;
const useGetIncidentTypesResponse = {
isLoading: false,
@ -85,7 +85,7 @@ describe('Connector', () => {
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetChoicesMock.mockReturnValue(useGetChoicesResponse);
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
});
it('it renders', async () => {

View file

@ -14,7 +14,7 @@ import type { ActionConnector } from '../../../common/types/domain';
import { ConnectorSelector } from '../connector_selector/form';
import { ConnectorFieldsForm } from '../connectors/fields_form';
import { schema } from './schema';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { getConnectorById, getConnectorsFormValidators } from '../utils';
import { useApplicationCapabilities } from '../../common/lib/kibana';
import * as i18n from '../../common/translations';
@ -29,7 +29,11 @@ interface Props {
const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingConnectors }) => {
const [{ connectorId }] = useFormData({ watch: ['connectorId'] });
const connector = getConnectorById(connectorId, connectors) ?? null;
const { connector: configurationConnector } = useCaseConfigure();
const {
data: { connector: configurationConnector },
} = useGetCaseConfiguration();
const { actions } = useApplicationCapabilities();
const { permissions } = useCasesContext();
const hasReadPermissions = permissions.connectors && actions.read;

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { FormTestComponent } from '../../common/test_utils';
import { customFieldsConfigurationMock } from '../../containers/mock';
import { CustomFields } from './custom_fields';
import * as i18n from './translations';
describe('CustomFields', () => {
let appMockRender: AppMockRenderer;
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} customFieldsConfiguration={customFieldsConfigurationMock} />
</FormTestComponent>
);
expect(screen.getByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument();
expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument();
for (const item of customFieldsConfigurationMock) {
expect(
screen.getByTestId(`${item.key}-${item.type}-create-custom-field`)
).toBeInTheDocument();
}
});
it('should not show the custom fields if the configuration is empty', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} customFieldsConfiguration={[]} />
</FormTestComponent>
);
expect(screen.queryByText(i18n.ADDITIONAL_FIELDS)).not.toBeInTheDocument();
expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0);
});
it('should sort the custom fields correctly', async () => {
const reversedConfiguration = [...customFieldsConfigurationMock].reverse();
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} customFieldsConfiguration={reversedConfiguration} />
</FormTestComponent>
);
const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields');
const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow');
expect(customFields).toHaveLength(2);
expect(customFields[0]).toHaveTextContent('My test label 1');
expect(customFields[1]).toHaveTextContent('My test label 2');
});
it('should update the custom fields', async () => {
appMockRender = createAppMockRenderer();
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<CustomFields isLoading={false} customFieldsConfiguration={customFieldsConfigurationMock} />
</FormTestComponent>
);
const textField = customFieldsConfigurationMock[0];
const toggleField = customFieldsConfigurationMock[1];
userEvent.type(
screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`),
'hello'
);
userEvent.click(
screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`)
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toHaveBeenCalledWith(
{
customFields: {
[textField.key]: 'hello',
[toggleField.key]: true,
},
},
true
);
});
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { sortBy } from 'lodash';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import type { CasesConfigurationUI } from '../../../common/ui';
import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder';
import * as i18n from './translations';
interface Props {
isLoading: boolean;
customFieldsConfiguration: CasesConfigurationUI['customFields'];
}
const CustomFieldsComponent: React.FC<Props> = ({ isLoading, customFieldsConfiguration }) => {
const sortedCustomFields = useMemo(
() => sortCustomFieldsByLabel(customFieldsConfiguration),
[customFieldsConfiguration]
);
const customFieldsComponents = sortedCustomFields.map(
(customField: CasesConfigurationUI['customFields'][number]) => {
const customFieldFactory = customFieldsBuilderMap[customField.type];
const customFieldType = customFieldFactory().build();
const CreateComponent = customFieldType.Create;
return (
<CreateComponent
isLoading={isLoading}
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

@ -14,12 +14,12 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { NONE_CONNECTOR_ID } from '../../../common/constants';
import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { connectorsMock } from '../../containers/mock';
import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock';
import type { FormProps } from './schema';
import { schema } from './schema';
import type { CreateCaseFormProps } from './form';
import { CreateCaseForm } from './form';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
import { TestProviders } from '../../common/mock';
import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors';
@ -28,13 +28,13 @@ import { useAvailableCasesOwners } from '../app/use_available_owners';
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_get_supported_action_connectors');
jest.mock('../../containers/configure/use_configure');
jest.mock('../../containers/configure/use_get_case_configuration');
jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment');
jest.mock('../app/use_available_owners');
const useGetTagsMock = useGetTags as jest.Mock;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock;
const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock;
const initialCaseValue: FormProps = {
@ -45,6 +45,7 @@ const initialCaseValue: FormProps = {
fields: null,
syncAlerts: true,
assignees: [],
customFields: {},
};
const casesFormProps: CreateCaseFormProps = {
@ -81,7 +82,7 @@ describe('CreateCaseForm', () => {
useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']);
useGetTagsMock.mockReturnValue({ data: ['test'] });
useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock });
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
});
afterEach(() => {
@ -218,6 +219,30 @@ describe('CreateCaseForm', () => {
expect(descriptionInput).toHaveValue('');
});
it('should render custom fields when available', () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
customFields: customFieldsConfigurationMock,
},
}));
const result = render(
<MockHookWrapperComponent>
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);
expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument();
for (const item of customFieldsConfigurationMock) {
expect(
result.getByTestId(`${item.key}-${item.type}-create-custom-field`)
).toBeInTheDocument();
}
});
it('should prefill the form when provided with initialValue', () => {
const { getByTestId } = render(
<MockHookWrapperComponent>

View file

@ -19,6 +19,7 @@ import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_
import type { ActionConnector } from '../../../common/types/domain';
import type { CasePostRequest } from '../../../common/types/api';
import type { CasesConfigurationUI } from '../../../common/ui';
import { Title } from './title';
import { Description, fieldName as descriptionFieldName } from './description';
import { Tags } from './tags';
@ -44,6 +45,7 @@ import { Assignees } from './assignees';
import { useCancelCreationAction } from './use_cancel_creation_action';
import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal';
import { Category } from './category';
import { CustomFields } from './custom_fields';
interface ContainerProps {
big?: boolean;
@ -64,6 +66,8 @@ const MySpinner = styled(EuiLoadingSpinner)`
export interface CreateCaseFormFieldsProps {
connectors: ActionConnector[];
customFieldsConfiguration: CasesConfigurationUI['customFields'];
isLoadingCaseConfiguration: boolean;
isLoadingConnectors: boolean;
withSteps: boolean;
owner: string[];
@ -83,7 +87,15 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr
const empty: ActionConnector[] = [];
export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo(
({ connectors, isLoadingConnectors, withSteps, owner, draftStorageKey }) => {
({
connectors,
isLoadingConnectors,
withSteps,
owner,
draftStorageKey,
customFieldsConfiguration,
isLoadingCaseConfiguration,
}) => {
const { isSubmitting } = useFormContext();
const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures();
const availableOwners = useAvailableCasesOwners();
@ -120,6 +132,12 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
<Container big>
<Description isLoading={isSubmitting} draftStorageKey={draftStorageKey} />
</Container>
<Container>
<CustomFields
isLoading={isSubmitting || isLoadingCaseConfiguration}
customFieldsConfiguration={customFieldsConfiguration}
/>
</Container>
<Container />
</>
),
@ -130,6 +148,8 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
canShowCaseSolutionSelection,
availableOwners,
draftStorageKey,
customFieldsConfiguration,
isLoadingCaseConfiguration,
]
);
@ -227,7 +247,9 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo(
>
<CreateCaseFormFields
connectors={empty}
customFieldsConfiguration={[]}
isLoadingConnectors={false}
isLoadingCaseConfiguration={false}
withSteps={withSteps}
owner={owner}
draftStorageKey={draftStorageKey}

View file

@ -15,7 +15,7 @@ import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { usePostCase } from '../../containers/use_post_case';
import { useCreateAttachments } from '../../containers/use_create_attachments';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types';
import { useGetSeverity } from '../connectors/resilient/use_get_severity';
import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types';
@ -46,15 +46,20 @@ import { waitForComponentToUpdate } from '../../common/test_utils';
import { userProfiles } from '../../containers/user_profiles/api.mock';
import { useLicense } from '../../common/use_license';
import { useGetCategories } from '../../containers/use_get_categories';
import { categories } from '../../containers/mock';
import { CaseSeverity, AttachmentType, ConnectorTypes } from '../../../common/types/domain';
import { categories, customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock';
import {
CaseSeverity,
AttachmentType,
ConnectorTypes,
CustomFieldTypes,
} from '../../../common/types/domain';
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_configure');
jest.mock('../../containers/configure/use_get_case_configuration');
jest.mock('../connectors/resilient/use_get_incident_types');
jest.mock('../connectors/resilient/use_get_severity');
jest.mock('../connectors/jira/use_get_issue_types');
@ -67,7 +72,7 @@ jest.mock('../../common/use_license');
jest.mock('../../containers/use_get_categories');
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock;
const usePostCaseMock = usePostCase as jest.Mock;
const useCreateAttachmentsMock = useCreateAttachments as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
@ -92,7 +97,9 @@ const defaultPostCase = {
const defaultCreateCaseForm: CreateCaseFormFieldsProps = {
isLoadingConnectors: false,
isLoadingCaseConfiguration: false,
connectors: [],
customFieldsConfiguration: [],
withSteps: true,
owner: ['securitySolution'],
draftStorageKey: 'cases.kibana.createCase.description.markdownEditor',
@ -191,7 +198,7 @@ describe.skip('Create case', () => {
useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments }));
usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService);
useGetConnectorsMock.mockReturnValue(sampleConnectorData);
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse);
@ -429,16 +436,78 @@ describe.skip('Create case', () => {
await waitForComponentToUpdate();
});
it('should select the default connector set in the configuration', async () => {
useCaseConfigureMock.mockImplementation(() => ({
it('should submit form with custom fields', async () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
data: {
...useCaseConfigureResponse.data,
customFields: [
...customFieldsConfigurationMock,
{
key: 'my_custom_field_key',
type: CustomFieldTypes.TEXT,
label: 'my custom field label',
required: false,
},
],
},
}));
appMockRender.render(
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
);
await waitForFormToRender(screen);
await fillFormReactTestingLib({ renderer: screen });
const textField = customFieldsConfigurationMock[0];
const toggleField = customFieldsConfigurationMock[1];
expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument();
userEvent.paste(
screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`),
'My text test value 1'
);
userEvent.click(
screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`)
);
userEvent.click(screen.getByTestId('create-case-submit'));
await waitFor(() => expect(postCase).toHaveBeenCalled());
expect(postCase).toBeCalledWith({
request: {
...sampleDataWithoutTags,
customFields: [
...customFieldsMock,
{
key: 'my_custom_field_key',
type: CustomFieldTypes.TEXT,
value: null,
},
],
},
});
});
it('should select the default connector set in the configuration', async () => {
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
data: {
...useCaseConfigureResponse.data,
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
},
persistLoading: false,
}));
useGetConnectorsMock.mockReturnValue({
@ -480,15 +549,17 @@ describe.skip('Create case', () => {
});
it('should default to none if the default connector does not exist in connectors', async () => {
useCaseConfigureMock.mockImplementation(() => ({
useGetCaseConfigurationMock.mockImplementation(() => ({
...useCaseConfigureResponse,
connector: {
id: 'not-exist',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
data: {
...useCaseConfigureResponse.data,
connector: {
id: 'not-exist',
name: 'SN',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
},
persistLoading: false,
}));
useGetConnectorsMock.mockReturnValue({

View file

@ -15,7 +15,7 @@ import { getNoneConnector, normalizeActionConnector } from '../configure_cases/u
import { usePostCase } from '../../containers/use_post_case';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import type { CaseUI } from '../../containers/types';
import type { CaseUI, CaseUICustomField } from '../../containers/types';
import type { CasePostRequest } from '../../../common/types/api';
import type { UseCreateAttachments } from '../../containers/use_create_attachments';
import { useCreateAttachments } from '../../containers/use_create_attachments';
@ -25,11 +25,13 @@ import {
getConnectorById,
getConnectorsFormDeserializer,
getConnectorsFormSerializer,
convertCustomFieldValue,
} from '../utils';
import { useAvailableCasesOwners } from '../app/use_available_owners';
import type { CaseAttachmentsWithoutOwner } from '../../types';
import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors';
import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
const initialCaseValue: FormProps = {
description: '',
@ -41,6 +43,7 @@ const initialCaseValue: FormProps = {
syncAlerts: true,
selectedOwner: null,
assignees: [],
customFields: {},
};
interface Props {
@ -63,6 +66,10 @@ export const FormContext: React.FC<Props> = ({
}) => {
const { data: connectors = [], isLoading: isLoadingConnectors } =
useGetSupportedActionConnectors();
const {
data: { customFields: customFieldsConfiguration },
isLoading: isLoadingCaseConfiguration,
} = useGetCaseConfiguration();
const { owner, appId } = useCasesContext();
const { isSyncAlertsEnabled } = useCasesFeatures();
const { mutateAsync: postCase } = usePostCase();
@ -89,6 +96,30 @@ export const FormContext: React.FC<Props> = ({
return formData;
};
const transformCustomFieldsData = useCallback(
(customFields: Record<string, string | boolean>) => {
const transformedCustomFields: CaseUI['customFields'] = [];
if (!customFields || !customFieldsConfiguration.length) {
return [];
}
for (const [key, value] of Object.entries(customFields)) {
const configCustomField = customFieldsConfiguration.find((item) => item.key === key);
if (configCustomField) {
transformedCustomFields.push({
key: configCustomField.key,
type: configCustomField.type,
value: convertCustomFieldValue(value),
} as CaseUICustomField);
}
}
return transformedCustomFields;
},
[customFieldsConfiguration]
);
const submitCase = useCallback(
async (
{
@ -100,7 +131,7 @@ export const FormContext: React.FC<Props> = ({
isValid
) => {
if (isValid) {
const { selectedOwner, ...userFormData } = dataWithoutConnectorId;
const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId;
const caseConnector = getConnectorById(dataConnectorId, connectors);
const defaultOwner = owner[0] ?? availableOwners[0];
@ -110,6 +141,8 @@ export const FormContext: React.FC<Props> = ({
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
const transformedCustomFields = transformCustomFieldsData(customFields);
const trimmedData = trimUserFormData(userFormData);
const theCase = await postCase({
@ -118,6 +151,7 @@ export const FormContext: React.FC<Props> = ({
connector: connectorToUpdate,
settings: { syncAlerts },
owner: selectedOwner ?? defaultOwner,
customFields: transformedCustomFields,
},
});
@ -159,6 +193,7 @@ export const FormContext: React.FC<Props> = ({
onSuccess,
createAttachments,
pushCaseToExternalService,
transformCustomFieldsData,
]
);
@ -175,10 +210,21 @@ export const FormContext: React.FC<Props> = ({
() =>
children != null
? React.Children.map(children, (child: React.ReactElement) =>
React.cloneElement(child, { connectors, isLoadingConnectors })
React.cloneElement(child, {
connectors,
isLoadingConnectors,
customFieldsConfiguration,
isLoadingCaseConfiguration,
})
)
: null,
[children, connectors, isLoadingConnectors]
[
children,
connectors,
isLoadingConnectors,
customFieldsConfiguration,
isLoadingCaseConfiguration,
]
);
return (
<Form

View file

@ -14,7 +14,7 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { TestProviders } from '../../common/mock';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration';
import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types';
import { useGetSeverity } from '../connectors/resilient/use_get_severity';
import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types';
@ -38,7 +38,7 @@ jest.mock('../../containers/api');
jest.mock('../../containers/user_profiles/api');
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_get_supported_action_connectors');
jest.mock('../../containers/configure/use_configure');
jest.mock('../../containers/configure/use_get_case_configuration');
jest.mock('../connectors/resilient/use_get_incident_types');
jest.mock('../connectors/resilient/use_get_severity');
jest.mock('../connectors/jira/use_get_issue_types');
@ -46,7 +46,7 @@ jest.mock('../connectors/jira/use_get_fields_by_issue_type');
jest.mock('../connectors/jira/use_get_issues');
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock;
const useGetTagsMock = useGetTags as jest.Mock;
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
@ -83,7 +83,7 @@ describe('CreateCase case', () => {
beforeEach(() => {
jest.clearAllMocks();
useGetConnectorsMock.mockReturnValue(sampleConnectorData);
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse);
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse);

View file

@ -28,6 +28,7 @@ export const sampleData: CasePostRequest = {
},
owner: SECURITY_SOLUTION_OWNER,
assignees: [],
customFields: [],
category: null,
};

View file

@ -72,11 +72,15 @@ export const schemaTags = {
],
};
export type FormProps = Omit<CasePostRequest, 'connector' | 'settings' | 'owner'> & {
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> = {

View file

@ -22,6 +22,10 @@ export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitl
defaultMessage: 'External Connector Fields',
});
export const ADDITIONAL_FIELDS = i18n.translate('xpack.cases.create.additionalFields', {
defaultMessage: 'Additional fields',
});
export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', {
defaultMessage: 'Sync alert status with case status',
});

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CustomFieldBuilderMap } from './types';
import { CustomFieldTypes } from '../../../common/types/domain';
import { configureTextCustomFieldFactory } from './text/configure_text_field';
import { configureToggleCustomFieldFactory } from './toggle/configure_toggle_field';
export const builderMap = Object.freeze({
[CustomFieldTypes.TEXT]: configureTextCustomFieldFactory,
[CustomFieldTypes.TOGGLE]: configureToggleCustomFieldFactory,
} as const) as CustomFieldBuilderMap;

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, within, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { customFieldsConfigurationMock } from '../../../containers/mock';
import { CustomFieldsList } from '.';
describe('CustomFieldsList', () => {
let appMockRender: AppMockRenderer;
const props = {
customFields: customFieldsConfigurationMock,
onDeleteCustomField: jest.fn(),
onEditCustomField: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', () => {
appMockRender.render(<CustomFieldsList {...props} />);
expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument();
});
it('shows CustomFieldsList correctly', async () => {
appMockRender.render(<CustomFieldsList {...props} />);
expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument();
for (const field of customFieldsConfigurationMock) {
expect(screen.getByTestId(`custom-field-${field.label}-${field.type}`)).toBeInTheDocument();
}
});
it('shows single CustomFieldsList correctly', async () => {
appMockRender.render(
<CustomFieldsList {...{ ...props, customFields: [customFieldsConfigurationMock[0]] }} />
);
const list = screen.getByTestId('custom-fields-list');
expect(list).toBeInTheDocument();
expect(
screen.getByTestId(
`custom-field-${customFieldsConfigurationMock[0].label}-${customFieldsConfigurationMock[0].type}`
)
).toBeInTheDocument();
expect(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
).toBeInTheDocument();
});
it('does not show any panel when custom fields', () => {
appMockRender.render(<CustomFieldsList {...{ ...props, customFields: [] }} />);
expect(screen.queryAllByTestId(`custom-field-`, { exact: false })).toHaveLength(0);
});
describe('Delete', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows confirmation modal when deleting a field ', async () => {
appMockRender.render(<CustomFieldsList {...props} />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
});
it('calls onDeleteCustomField when confirm', async () => {
appMockRender.render(<CustomFieldsList {...props} />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
userEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument();
expect(props.onDeleteCustomField).toHaveBeenCalledWith(
customFieldsConfigurationMock[0].key
);
});
});
it('does not call onDeleteCustomField when cancel', async () => {
appMockRender.render(<CustomFieldsList {...props} />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
userEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument();
expect(props.onDeleteCustomField).not.toHaveBeenCalledWith();
});
});
});
describe('Edit', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls onEditCustomField correctly', async () => {
appMockRender.render(<CustomFieldsList {...props} />);
const list = screen.getByTestId('custom-fields-list');
userEvent.click(
within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
);
await waitFor(() => {
expect(props.onEditCustomField).toHaveBeenCalledWith(customFieldsConfigurationMock[0].key);
});
});
});
});

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import {
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
EuiIcon,
EuiButtonIcon,
} from '@elastic/eui';
import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain';
import { builderMap } from '../builder';
import { DeleteConfirmationModal } from '../delete_confirmation_modal';
export interface Props {
customFields: CustomFieldsConfiguration;
onDeleteCustomField: (key: string) => void;
onEditCustomField: (key: string) => void;
}
const CustomFieldsListComponent: React.FC<Props> = (props) => {
const { customFields, onDeleteCustomField, onEditCustomField } = props;
const [selectedItem, setSelectedItem] = useState<CustomFieldsConfiguration[number] | null>(null);
const renderTypeLabel = (type?: CustomFieldTypes) => {
const createdBuilder = type && builderMap[type];
return createdBuilder && createdBuilder().label;
};
const onConfirm = useCallback(() => {
if (selectedItem) {
onDeleteCustomField(selectedItem.key);
}
setSelectedItem(null);
}, [onDeleteCustomField, setSelectedItem, selectedItem]);
const onCancel = useCallback(() => {
setSelectedItem(null);
}, []);
const showModal = Boolean(selectedItem);
return customFields.length ? (
<>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexStart" data-test-subj="custom-fields-list">
<EuiFlexItem>
{customFields.map((customField) => (
<React.Fragment key={customField.key}>
<EuiPanel
paddingSize="s"
data-test-subj={`custom-field-${customField.label}-${customField.type}`}
hasShadow={false}
>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="grab" />
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText>
<h4>{customField.label}</h4>
</EuiText>
</EuiFlexItem>
<EuiText color="subdued">{renderTypeLabel(customField.type)}</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj={`${customField.key}-custom-field-edit`}
aria-label={`${customField.key}-custom-field-edit`}
iconType="pencil"
color="primary"
onClick={() => onEditCustomField(customField.key)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj={`${customField.key}-custom-field-delete`}
aria-label={`${customField.key}-custom-field-delete`}
iconType="minusInCircle"
color="danger"
onClick={() => setSelectedItem(customField)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer size="s" />
</React.Fragment>
))}
</EuiFlexItem>
{showModal && selectedItem ? (
<DeleteConfirmationModal
label={selectedItem.label}
onCancel={onCancel}
onConfirm={onConfirm}
/>
) : null}
</EuiFlexGroup>
</>
) : null;
};
CustomFieldsListComponent.displayName = 'CustomFieldsList';
export const CustomFieldsList = React.memo(CustomFieldsListComponent);

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import userEvent from '@testing-library/user-event';
import React from 'react';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { DeleteConfirmationModal } from './delete_confirmation_modal';
describe('DeleteConfirmationModal', () => {
let appMock: AppMockRenderer;
const props = {
label: 'My custom field',
onCancel: jest.fn(),
onConfirm: jest.fn(),
};
beforeEach(() => {
appMock = createAppMockRenderer();
jest.clearAllMocks();
});
it('renders correctly', async () => {
const result = appMock.render(<DeleteConfirmationModal {...props} />);
expect(result.getByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
expect(result.getByText('Delete')).toBeInTheDocument();
expect(result.getByText('Cancel')).toBeInTheDocument();
});
it('calls onConfirm', async () => {
const result = appMock.render(<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,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import * as i18n from './translations';
interface ConfirmDeleteCaseModalProps {
label: string;
onCancel: () => void;
onConfirm: () => void;
}
const DeleteConfirmationModalComponent: React.FC<ConfirmDeleteCaseModalProps> = ({
label,
onCancel,
onConfirm,
}) => {
return (
<EuiConfirmModal
buttonColor="danger"
cancelButtonText={i18n.CANCEL}
data-test-subj="confirm-delete-custom-field-modal"
defaultFocusedButton="confirm"
onCancel={onCancel}
onConfirm={onConfirm}
title={i18n.DELETE_FIELD_TITLE(label)}
confirmButtonText={i18n.DELETE}
>
{i18n.DELETE_FIELD_DESCRIPTION}
</EuiConfirmModal>
);
};
DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal';
export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent);

View file

@ -0,0 +1,137 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { CustomFieldFlyout } from './flyout';
import { customFieldsConfigurationMock } from '../../containers/mock';
import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants';
import * as i18n from './translations';
describe('CustomFieldFlyout ', () => {
let appMockRender: AppMockRenderer;
const props = {
onCloseFlyout: jest.fn(),
onSaveField: jest.fn(),
isLoading: false,
disabled: false,
customField: null,
};
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
expect(screen.getByTestId('custom-field-flyout-header')).toBeInTheDocument();
expect(screen.getByTestId('custom-field-flyout-cancel')).toBeInTheDocument();
expect(screen.getByTestId('custom-field-flyout-save')).toBeInTheDocument();
});
it('calls onSaveField on save field', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary');
userEvent.click(screen.getByTestId('text-custom-field-options'));
userEvent.click(screen.getByTestId('custom-field-flyout-save'));
await waitFor(() => {
expect(props.onSaveField).toBeCalledWith({
key: expect.anything(),
label: 'Summary',
required: true,
type: 'text',
});
});
});
it('shows error if field label is too long', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1);
userEvent.type(screen.getByTestId('custom-field-label-input'), message);
await waitFor(() => {
expect(
screen.getByText(i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH))
).toBeInTheDocument();
});
});
it('calls onSaveField with serialized data', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary');
userEvent.click(screen.getByTestId('custom-field-flyout-save'));
await waitFor(() => {
expect(props.onSaveField).toBeCalledWith({
key: expect.anything(),
label: 'Summary',
required: false,
type: 'text',
});
});
});
it('does not call onSaveField when error', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
userEvent.click(screen.getByTestId('custom-field-flyout-save'));
await waitFor(() => {
expect(screen.getByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL))).toBeInTheDocument();
});
expect(props.onSaveField).not.toBeCalled();
});
it('calls onCloseFlyout on cancel', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
userEvent.click(screen.getByTestId('custom-field-flyout-cancel'));
await waitFor(() => {
expect(props.onCloseFlyout).toBeCalled();
});
});
it('calls onCloseFlyout on close', async () => {
appMockRender.render(<CustomFieldFlyout {...props} />);
userEvent.click(screen.getByTestId('euiFlyoutCloseButton'));
await waitFor(() => {
expect(props.onCloseFlyout).toBeCalled();
});
});
it('renders flyout with data when customField value exist', 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-options')).toHaveAttribute('checked');
});
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
import type { CustomFieldFormState } from './form';
import { CustomFieldsForm } from './form';
import type { CustomFieldConfiguration } from '../../../common/types/domain';
import { CustomFieldTypes } from '../../../common/types/domain';
import * as i18n from './translations';
export interface CustomFieldFlyoutProps {
disabled: boolean;
isLoading: boolean;
onCloseFlyout: () => void;
onSaveField: (data: CustomFieldConfiguration) => void;
customField: CustomFieldConfiguration | null;
}
const CustomFieldFlyoutComponent: React.FC<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

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, fireEvent, waitFor, act } from '@testing-library/react';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import type { CustomFieldFormState } from './form';
import { CustomFieldsForm } from './form';
import { CustomFieldTypes } from '../../../common/types/domain';
import * as i18n from './translations';
import userEvent from '@testing-library/user-event';
import { customFieldsConfigurationMock } from '../../containers/mock';
describe('CustomFieldsForm ', () => {
let appMockRender: AppMockRenderer;
const onChange = jest.fn();
const props = {
onChange,
initialValue: null,
};
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', async () => {
appMockRender.render(<CustomFieldsForm {...props} />);
expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument();
expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument();
});
it('renders text as default custom field type', async () => {
appMockRender.render(<CustomFieldsForm {...props} />);
expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument();
expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument();
});
it('renders custom field type options', async () => {
appMockRender.render(<CustomFieldsForm {...props} />);
expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText('Toggle')).toBeInTheDocument();
expect(screen.getByTestId('custom-field-type-selector')).not.toHaveAttribute('disabled');
});
it('renders toggle custom field type', async () => {
appMockRender.render(<CustomFieldsForm {...props} />);
fireEvent.change(screen.getByTestId('custom-field-type-selector'), {
target: { value: CustomFieldTypes.TOGGLE },
});
expect(screen.getByTestId('toggle-custom-field-options')).toBeInTheDocument();
expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument();
});
it('serializes the data correctly if required is selected', async () => {
let formState: CustomFieldFormState;
const onChangeState = (state: CustomFieldFormState) => (formState = state);
appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />);
await waitFor(() => {
expect(formState).not.toBeUndefined();
});
userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary');
userEvent.click(screen.getByTestId('text-custom-field-options'));
await act(async () => {
const { data } = await formState!.submit();
expect(data).toEqual({
key: expect.anything(),
label: 'Summary',
required: true,
type: 'text',
});
});
});
it('serializes the data correctly if required is not selected', async () => {
let formState: CustomFieldFormState;
const onChangeState = (state: CustomFieldFormState) => (formState = state);
appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />);
await waitFor(() => {
expect(formState).not.toBeUndefined();
});
userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary');
await act(async () => {
const { data } = await formState!.submit();
expect(data).toEqual({
key: expect.anything(),
label: 'Summary',
required: false,
type: 'text',
});
});
});
it('deserializes the data correctly if required is selected', async () => {
let formState: CustomFieldFormState;
const onChangeState = (state: CustomFieldFormState) => (formState = state);
appMockRender.render(
<CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[0]} />
);
await waitFor(() => {
expect(formState).not.toBeUndefined();
});
expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled');
expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute(
'value',
customFieldsConfigurationMock[0].label
);
expect(await screen.findByTestId('text-custom-field-options')).toHaveAttribute('checked');
await act(async () => {
const { data } = await formState!.submit();
expect(data).toEqual(customFieldsConfigurationMock[0]);
});
});
it('deserializes the data correctly if required not selected', async () => {
let formState: CustomFieldFormState;
const onChangeState = (state: CustomFieldFormState) => (formState = state);
appMockRender.render(
<CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[1]} />
);
await waitFor(() => {
expect(formState).not.toBeUndefined();
});
expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled');
expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute(
'value',
customFieldsConfigurationMock[1].label
);
expect(await screen.findByTestId('text-custom-field-options')).not.toHaveAttribute('checked');
await act(async () => {
const { data } = await formState!.submit();
expect(data).toEqual(customFieldsConfigurationMock[1]);
});
});
});

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import React, { useEffect, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { CustomFieldsConfigurationFormProps } from './schema';
import { schema } from './schema';
import { FormFields } from './form_fields';
import type { CustomFieldConfiguration } from '../../../common/types/domain';
import { CustomFieldTypes } from '../../../common/types/domain';
export interface CustomFieldFormState {
isValid: boolean | undefined;
submit: FormHook<CustomFieldConfiguration>['submit'];
}
interface Props {
onChange: (state: CustomFieldFormState) => void;
initialValue: CustomFieldConfiguration | null;
}
// Form -> API
const formSerializer = ({
key,
label,
type,
options,
}: CustomFieldsConfigurationFormProps): CustomFieldConfiguration => {
return {
key,
label,
type,
required: options?.required ? options.required : false,
};
};
// API -> Form
const formDeserializer = ({
key,
label,
type,
required,
}: CustomFieldConfiguration): CustomFieldsConfigurationFormProps => {
return {
key,
options: { required: Boolean(required) },
label,
type,
};
};
const FormComponent: React.FC<Props> = ({ onChange, initialValue }) => {
const keyDefaultValue = useMemo(() => uuidv4(), []);
const { form } = useForm({
defaultValue: initialValue ?? {
key: keyDefaultValue,
label: '',
type: CustomFieldTypes.TEXT,
required: false,
},
options: { stripEmptyFields: false },
schema,
serializer: formSerializer,
deserializer: formDeserializer,
});
const { submit, isValid, isSubmitting } = form;
useEffect(() => {
if (onChange) {
onChange({ isValid, submit });
}
}, [onChange, isValid, submit]);
return (
<Form form={form}>
<FormFields isSubmitting={isSubmitting} isEditMode={Boolean(initialValue)} />
</Form>
);
};
FormComponent.displayName = 'CustomFieldsForm';
export const CustomFieldsForm = React.memo(FormComponent);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { FormTestComponent } from '../../common/test_utils';
import { CustomFieldTypes } from '../../../common/types/domain';
import { FormFields } from './form_fields';
describe('FormFields ', () => {
let appMockRender: AppMockRenderer;
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<FormFields />
</FormTestComponent>
);
expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument();
expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument();
});
it('disables field type selector on edit mode', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<FormFields isEditMode />
</FormTestComponent>
);
expect(screen.getByTestId('custom-field-type-selector')).toHaveAttribute('disabled');
});
it('submit data correctly', async () => {
appMockRender.render(
<FormTestComponent onSubmit={onSubmit}>
<FormFields />
</FormTestComponent>
);
userEvent.type(screen.getByTestId('custom-field-label-input'), 'hello');
fireEvent.change(screen.getByTestId('custom-field-type-selector'), {
target: { value: CustomFieldTypes.TOGGLE },
});
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toBeCalledWith(
{
label: 'hello',
type: 'toggle',
},
true
);
});
});
});

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import {
TextField,
SelectField,
HiddenField,
} from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { EuiSelectOption } from '@elastic/eui';
import { CustomFieldTypes } from '../../../common/types/domain';
import { builderMap } from './builder';
interface FormFieldsProps {
isSubmitting?: boolean;
isEditMode?: boolean;
}
const fieldTypeSelectOptions = (): EuiSelectOption[] => {
const options = [];
for (const [id, builder] of Object.entries(builderMap)) {
const createdBuilder = builder();
options.push({ value: id, text: createdBuilder.label });
}
return options;
};
const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting, isEditMode }) => {
const [selectedType, setSelectedType] = useState<CustomFieldTypes>(CustomFieldTypes.TEXT);
const handleTypeChange = useCallback(
(e) => {
setSelectedType(e.target.value);
},
[setSelectedType]
);
const builtCustomField = useMemo(() => {
const builder = builderMap[selectedType];
if (builder == null) {
return null;
}
const customFieldBuilder = builder();
return customFieldBuilder.build();
}, [selectedType]);
const Configure = builtCustomField?.Configure;
const options = fieldTypeSelectOptions();
return (
<>
<UseField path="key" component={HiddenField} />
<UseField
path="label"
component={TextField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'custom-field-label-input',
fullWidth: true,
autoFocus: true,
isLoading: isSubmitting,
},
}}
/>
<UseField
component={SelectField}
path="type"
componentProps={{
euiFieldProps: {
options,
'data-test-subj': 'custom-field-type-selector',
isLoading: isSubmitting,
disabled: isEditMode,
},
onChange: handleTypeChange,
}}
/>
{Configure ? <Configure /> : null}
</>
);
};
FormFieldsComponent.displayName = 'FormFields';
export const FormFields = memo(FormFieldsComponent);

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from '@testing-library/dom';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { customFieldsConfigurationMock } from '../../containers/mock';
import { CustomFieldTypes } from '../../../common/types/domain';
import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants';
import { CustomFields } from '.';
import * as i18n from './translations';
describe('CustomFields', () => {
let appMockRender: AppMockRenderer;
const props = {
disabled: false,
isLoading: false,
handleAddCustomField: jest.fn(),
handleDeleteCustomField: jest.fn(),
handleEditCustomField: jest.fn(),
customFields: [],
};
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('renders correctly', () => {
appMockRender.render(<CustomFields {...props} />);
expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument();
expect(screen.getByTestId('add-custom-field')).toBeInTheDocument();
});
it('renders custom fields correctly', () => {
appMockRender.render(
<CustomFields {...{ ...props, customFields: customFieldsConfigurationMock }} />
);
expect(screen.getByTestId('add-custom-field')).toBeInTheDocument();
expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument();
});
it('renders loading state correctly', () => {
appMockRender.render(<CustomFields {...{ ...props, isLoading: true }} />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('renders disabled state correctly', () => {
appMockRender.render(<CustomFields {...{ ...props, disabled: true }} />);
expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled');
});
it('calls onChange on add option click', async () => {
appMockRender.render(<CustomFields {...props} />);
userEvent.click(screen.getByTestId('add-custom-field'));
expect(props.handleAddCustomField).toBeCalled();
});
it('calls handleEditCustomField on edit option click', async () => {
appMockRender.render(
<CustomFields {...{ ...props, customFields: customFieldsConfigurationMock }} />
);
userEvent.click(
screen.getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
);
expect(props.handleEditCustomField).toBeCalledWith(customFieldsConfigurationMock[0].key);
});
it('shows the experimental badge', () => {
appMockRender.render(<CustomFields {...props} />);
expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument();
});
it('shows error when custom fields reaches the limit', async () => {
const generatedMockCustomFields = [];
for (let i = 0; i < 8; i++) {
generatedMockCustomFields.push({
key: `field_key_${i + 1}`,
label: `My custom label ${i + 1}`,
type: CustomFieldTypes.TEXT,
required: false,
});
}
const customFields = [...customFieldsConfigurationMock, ...generatedMockCustomFields];
appMockRender.render(<CustomFields {...{ ...props, customFields }} />);
userEvent.click(screen.getByTestId('add-custom-field'));
await waitFor(() => {
expect(screen.getByText(i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)));
expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled');
});
});
});

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useCallback } from 'react';
import {
EuiButtonEmpty,
EuiPanel,
EuiDescribedFormGroup,
EuiSpacer,
EuiText,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import * as i18n from './translations';
import { useCasesContext } from '../cases_context/use_cases_context';
import type { CustomFieldsConfiguration } from '../../../common/types/domain';
import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants';
import { CustomFieldsList } from './custom_fields_list';
import { ExperimentalBadge } from '../experimental_badge/experimental_badge';
export interface Props {
customFields: CustomFieldsConfiguration;
disabled: boolean;
isLoading: boolean;
handleAddCustomField: () => void;
handleDeleteCustomField: (key: string) => void;
handleEditCustomField: (key: string) => void;
}
const CustomFieldsComponent: React.FC<Props> = ({
disabled,
isLoading,
handleAddCustomField,
handleDeleteCustomField,
handleEditCustomField,
customFields,
}) => {
const { permissions } = useCasesContext();
const canAddCustomFields = permissions.create && permissions.update;
const [error, setError] = useState<boolean>(false);
const onAddCustomField = useCallback(() => {
if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) {
setError(true);
return;
}
handleAddCustomField();
setError(false);
}, [handleAddCustomField, setError, customFields, error]);
const onEditCustomField = useCallback(
(key: string) => {
setError(false);
handleEditCustomField(key);
},
[setError, handleEditCustomField]
);
if (customFields.length < MAX_CUSTOM_FIELDS_PER_CASE && error) {
setError(false);
}
return canAddCustomFields ? (
<EuiDescribedFormGroup
fullWidth
title={
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>{i18n.TITLE}</EuiFlexItem>
<EuiFlexItem grow={false}>
<ExperimentalBadge />
</EuiFlexItem>
</EuiFlexGroup>
}
description={<p>{i18n.DESCRIPTION}</p>}
data-test-subj="custom-fields-form-group"
>
<EuiPanel paddingSize="s" color="subdued" hasBorder={false} hasShadow={false}>
{customFields.length ? (
<>
<CustomFieldsList
customFields={customFields}
onDeleteCustomField={handleDeleteCustomField}
onEditCustomField={onEditCustomField}
/>
{error ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiText color="danger">
{i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
</>
) : null}
<EuiSpacer size="m" />
{!customFields.length ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
{i18n.NO_CUSTOM_FIELDS}
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
) : null}
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
isLoading={isLoading}
isDisabled={disabled || error}
size="s"
onClick={onAddCustomField}
iconType="plusInCircle"
data-test-subj="add-custom-field"
>
{i18n.ADD_CUSTOM_FIELD}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiDescribedFormGroup>
) : null;
};
CustomFieldsComponent.displayName = 'CustomFields';
export const CustomFields = React.memo(CustomFieldsComponent);

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import * as i18n from './translations';
import type { CustomFieldTypes } from '../../../common/types/domain';
import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants';
const { emptyField, maxLengthField } = fieldValidators;
export interface CustomFieldsConfigurationFormProps {
key: string;
label: string;
type: CustomFieldTypes;
options?: {
required?: boolean;
};
}
export const schema = {
key: {
validations: [
{
validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)),
},
],
},
label: {
label: i18n.FIELD_LABEL,
validations: [
{
validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)),
},
{
validator: maxLengthField({
length: MAX_CUSTOM_FIELD_LABEL_LENGTH,
message: i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH),
}),
},
],
},
type: {
label: i18n.FIELD_TYPE,
validations: [
{
validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)),
},
],
},
};

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants';
import { MAX_LENGTH_ERROR, REQUIRED_FIELD } from '../translations';
const { emptyField } = fieldValidators;
export const getTextFieldConfig = ({
required,
label,
}: {
required: boolean;
label: string;
}): FieldConfig<string> => {
const validators = [];
if (required) {
validators.push({
validator: emptyField(REQUIRED_FIELD(label)),
});
}
return {
validations: [
...validators,
{
validator: ({ value }) => {
if (value == null) {
return;
}
if (value.length > MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) {
return {
message: MAX_LENGTH_ERROR(label, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH),
};
}
},
},
],
};
};

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 React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormTestComponent } from '../../../common/test_utils';
import * as i18n from '../translations';
import { Configure } from './configure';
describe('Configure ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Configure />
</FormTestComponent>
);
expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument();
});
it('updates field options correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Configure />
</FormTestComponent>
);
userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED));
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toBeCalledWith(
{
options: {
required: true,
},
},
true
);
});
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
import * as i18n from '../translations';
const ConfigureComponent: CustomFieldType<CaseCustomFieldText>['Configure'] = () => {
return (
<>
<UseField
path="options.required"
component={CheckBoxField}
componentProps={{
label: i18n.FIELD_OPTIONS,
euiFieldProps: {
label: i18n.FIELD_OPTION_REQUIRED,
'data-test-subj': 'text-custom-field-options',
},
}}
/>
</>
);
};
ConfigureComponent.displayName = 'Configure';
export const Configure = React.memo(ConfigureComponent);

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { configureTextCustomFieldFactory } from './configure_text_field';
describe('configureTextCustomFieldFactory ', () => {
const builder = configureTextCustomFieldFactory();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
expect(builder).toEqual({
id: 'text',
label: 'Text',
build: expect.any(Function),
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CustomFieldFactory } from '../types';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
import { CustomFieldTypes } from '../../../../common/types/domain';
import * as i18n from '../translations';
import { Edit } from './edit';
import { View } from './view';
import { Configure } from './configure';
import { Create } from './create';
export const configureTextCustomFieldFactory: CustomFieldFactory<CaseCustomFieldText> = () => ({
id: CustomFieldTypes.TEXT,
label: i18n.TEXT_LABEL,
build: () => ({
Configure,
Edit,
View,
Create,
}),
});

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormTestComponent } from '../../../common/test_utils';
import { Create } from './create';
import { customFieldsConfigurationMock } from '../../../containers/mock';
import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants';
describe('Create ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const customFieldConfiguration = customFieldsConfigurationMock[0];
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={false} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument();
expect(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`)
).toBeInTheDocument();
});
it('renders loading state correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={true} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('disables the text when loading', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={true} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
expect(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`)
).toHaveAttribute('disabled');
});
it('updates the value correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={false} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
userEvent.type(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`),
'this is a sample text!'
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toHaveBeenCalledWith(
{
customFields: {
[customFieldConfiguration.key]: 'this is a sample text!',
},
},
true
);
});
});
it('shows error when text is too long', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={false} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1);
userEvent.paste(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`),
sampleText
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(
screen.getByText(
`The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.`
)
).toBeInTheDocument();
expect(onSubmit).toHaveBeenCalledWith({}, false);
});
});
it('shows error when text is too long and field is optional', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create
isLoading={false}
customFieldConfiguration={{ ...customFieldConfiguration, required: false }}
/>
</FormTestComponent>
);
const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1);
userEvent.paste(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`),
sampleText
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(
screen.getByText(
`The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.`
)
).toBeInTheDocument();
expect(onSubmit).toHaveBeenCalledWith({}, false);
});
});
it('shows error when text is required but is empty', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create
isLoading={false}
customFieldConfiguration={{ ...customFieldConfiguration, required: true }}
/>
</FormTestComponent>
);
userEvent.paste(
screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`),
''
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(
screen.getByText(`${customFieldConfiguration.label} is required.`)
).toBeInTheDocument();
expect(onSubmit).toHaveBeenCalledWith({}, false);
});
});
it('does not show error when text is not required but is empty', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create
isLoading={false}
customFieldConfiguration={{ ...customFieldConfiguration, required: false }}
/>
</FormTestComponent>
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({}, true);
});
});
});

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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
import { getTextFieldConfig } from './config';
const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({
customFieldConfiguration,
isLoading,
}) => {
const { key, label, required } = customFieldConfiguration;
const config = getTextFieldConfig({ required, label });
return (
<UseField
path={`customFields.${key}`}
config={config}
component={TextField}
label={label}
componentProps={{
euiFieldProps: {
'data-test-subj': `${key}-text-create-custom-field`,
fullWidth: true,
disabled: isLoading,
isLoading,
},
}}
/>
);
};
CreateComponent.displayName = 'Create';
export const Create = React.memo(CreateComponent);

View file

@ -0,0 +1,381 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { FormTestComponent } from '../../../common/test_utils';
import { Edit } from './edit';
import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock';
import userEvent from '@testing-library/user-event';
import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
describe('Edit ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const customField = customFieldsMock[0] as CaseCustomFieldText;
const customFieldConfiguration = customFieldsConfigurationMock[0];
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByTestId('case-text-custom-field-test_key_1')).toBeInTheDocument();
expect(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')).toBeInTheDocument();
expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument();
expect(screen.getByText('My text test value 1')).toBeInTheDocument();
});
it('does not shows the edit button if the user does not have permissions', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={false}
/>
</FormTestComponent>
);
expect(
screen.queryByTestId('case-text-custom-field-edit-button-test_key_1')
).not.toBeInTheDocument();
});
it('does not shows the edit button when loading', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={true}
canUpdate={true}
/>
</FormTestComponent>
);
expect(
screen.queryByTestId('case-text-custom-field-edit-button-test_key_1')
).not.toBeInTheDocument();
});
it('shows the loading spinner when loading', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={true}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByTestId('case-text-custom-field-loading-test_key_1')).toBeInTheDocument();
});
it('shows the no value text if the custom field is undefined', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument();
});
it('shows the no value text if the the value is null', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={{ ...customField, value: null }}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument();
});
it('does not show the value when the custom field is undefined', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument();
});
it('does not show the value when the value is null', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={{ ...customField, value: null }}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument();
});
it('does not show the form when the user does not have permissions', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={false}
/>
</FormTestComponent>
);
expect(
screen.queryByTestId('case-text-custom-field-form-field-test_key_1')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('case-text-custom-field-submit-button-test_key_1')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('case-text-custom-field-cancel-button-test_key_1')
).not.toBeInTheDocument();
});
it('calls onSubmit when changing value', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!');
await waitFor(() => {
expect(
screen.getByTestId('case-text-custom-field-submit-button-test_key_1')
).not.toBeDisabled();
});
userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith({
...customField,
value: ['My text test value 1!!!'],
});
});
});
it('sets the value to null if the text field is empty', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={{ ...customFieldConfiguration, required: false }}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1'));
await waitFor(() => {
expect(
screen.getByTestId('case-text-custom-field-submit-button-test_key_1')
).not.toBeDisabled();
});
userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith({
...customField,
value: null,
});
});
});
it('hides the form when clicking the cancel button', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toBeInTheDocument();
userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1'));
expect(
screen.queryByTestId('case-text-custom-field-form-field-test_key_1')
).not.toBeInTheDocument();
});
it('reset to initial value when canceling', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!');
await waitFor(() => {
expect(
screen.getByTestId('case-text-custom-field-submit-button-test_key_1')
).not.toBeDisabled();
});
userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1'));
expect(
screen.queryByTestId('case-text-custom-field-form-field-test_key_1')
).not.toBeInTheDocument();
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toHaveValue(
'My text test value 1'
);
});
it('shows validation error if the field is required', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1'));
await waitFor(() => {
expect(screen.getByText('My test label 1 is required.')).toBeInTheDocument();
});
});
it('does not shows a validation error if the field is not required', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={{ ...customFieldConfiguration, required: false }}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1'));
await waitFor(() => {
expect(
screen.getByTestId('case-text-custom-field-submit-button-test_key_1')
).not.toBeDisabled();
});
expect(screen.queryByText('My test label 1 is required.')).not.toBeInTheDocument();
});
it('shows validation error if the field is too long', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1'));
userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1'));
userEvent.paste(
screen.getByTestId('case-text-custom-field-form-field-test_key_1'),
'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)
);
await waitFor(() => {
expect(
screen.getByText(
`The length of the My test label 1 is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.`
)
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,218 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash';
import React, { useEffect, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { useForm, UseField, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
import { CustomFieldTypes } from '../../../../common/types/domain';
import type { CasesConfigurationUICustomField } from '../../../../common/ui';
import type { CustomFieldType } from '../types';
import { View } from './view';
import { CANCEL, EDIT_CUSTOM_FIELDS_ARIA_LABEL, NO_CUSTOM_FIELD_SET, SAVE } from '../translations';
import { getTextFieldConfig } from './config';
interface FormState {
isValid: boolean | undefined;
submit: FormHook<{ value: string }>['submit'];
}
interface FormWrapper {
initialValue: string;
isLoading: boolean;
customFieldConfiguration: CasesConfigurationUICustomField;
onChange: (state: FormState) => void;
}
const FormWrapperComponent: React.FC<FormWrapper> = ({
initialValue,
customFieldConfiguration,
isLoading,
onChange,
}) => {
const { form } = useForm({
defaultValue: { value: initialValue },
});
const { submit, isValid: isFormValid } = form;
useEffect(() => {
onChange({ isValid: isFormValid, submit });
}, [isFormValid, onChange, submit]);
const formFieldConfig = getTextFieldConfig({
required: customFieldConfiguration.required,
label: customFieldConfiguration.label,
});
return (
<Form form={form}>
<UseField
path="value"
config={formFieldConfig}
component={TextField}
componentProps={{
euiFieldProps: {
fullWidth: true,
disabled: isLoading,
isLoading,
'data-test-subj': `case-text-custom-field-form-field-${customFieldConfiguration.key}`,
},
}}
/>
</Form>
);
};
FormWrapperComponent.displayName = 'FormWrapper';
const EditComponent: CustomFieldType<CaseCustomFieldText>['Edit'] = ({
customField,
customFieldConfiguration,
onSubmit,
isLoading,
canUpdate,
}) => {
const [isEdit, setIsEdit] = useState(false);
const [formState, setFormState] = useState<FormState>({
isValid: undefined,
submit: async () => ({ isValid: false, data: { value: '' } }),
});
const onEdit = () => {
setIsEdit(true);
};
const onCancel = () => {
setIsEdit(false);
};
const onSubmitCustomField = async () => {
const { isValid, data } = await formState.submit();
if (isValid) {
const value = isEmpty(data.value) ? null : [data.value];
onSubmit({
...customField,
key: customField?.key ?? customFieldConfiguration.key,
type: CustomFieldTypes.TEXT,
value,
});
}
setIsEdit(false);
};
const initialValue = customField?.value?.[0] ?? '';
const title = customFieldConfiguration.label;
const isTextFieldValid = formState.isValid;
const isCustomFieldValueDefined = !isEmpty(customField?.value);
return (
<>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText>
<h4>{title}</h4>
</EuiText>
</EuiFlexItem>
{isLoading && (
<EuiLoadingSpinner
data-test-subj={`case-text-custom-field-loading-${customFieldConfiguration.key}`}
/>
)}
{!isLoading && canUpdate && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj={`case-text-custom-field-edit-button-${customFieldConfiguration.key}`}
aria-label={EDIT_CUSTOM_FIELDS_ARIA_LABEL(title)}
iconType={'pencil'}
onClick={onEdit}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup
gutterSize="m"
data-test-subj={`case-text-custom-field-${customFieldConfiguration.key}`}
direction="column"
>
{!isCustomFieldValueDefined && !isEdit && (
<p data-test-subj="no-tags">{NO_CUSTOM_FIELD_SET(customFieldConfiguration.label)}</p>
)}
{!isEdit && isCustomFieldValueDefined && (
<EuiFlexItem>
<View customField={customField} />
</EuiFlexItem>
)}
{isEdit && canUpdate && (
<EuiFlexGroup gutterSize="m" direction="column">
<EuiFlexItem>
<FormWrapperComponent
initialValue={initialValue}
isLoading={isLoading}
onChange={setFormState}
customFieldConfiguration={customFieldConfiguration}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
color="success"
data-test-subj={`case-text-custom-field-submit-button-${customFieldConfiguration.key}`}
fill
iconType="save"
onClick={onSubmitCustomField}
size="s"
disabled={!isTextFieldValid || isLoading}
>
{SAVE}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj={`case-text-custom-field-cancel-button-${customFieldConfiguration.key}`}
iconType="cross"
onClick={onCancel}
size="s"
>
{CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexGroup>
</>
);
};
EditComponent.displayName = 'Edit';
export const Edit = React.memo(EditComponent);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { CustomFieldTypes } from '../../../../common/types/domain';
import { View } from './view';
describe('View ', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const customField = {
type: CustomFieldTypes.TEXT as const,
key: 'test_key_1',
value: ['My text test value'],
};
it('renders correctly', async () => {
render(<View customField={customField} />);
expect(screen.getByText('My text test value')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiText } from '@elastic/eui';
import type { CaseCustomFieldText } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
const ViewComponent: CustomFieldType<CaseCustomFieldText>['View'] = ({ customField }) => {
const value = customField?.value?.[0] ?? '-';
return <EuiText data-test-subj={`text-custom-field-view-${customField?.key}`}>{value}</EuiText>;
};
ViewComponent.displayName = 'View';
export const View = React.memo(ViewComponent);

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 React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormTestComponent } from '../../../common/test_utils';
import * as i18n from '../translations';
import { Configure } from './configure';
describe('Configure ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Configure />
</FormTestComponent>
);
expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument();
});
it('updates field options correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Configure />
</FormTestComponent>
);
userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED));
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toBeCalledWith(
{
options: {
required: true,
},
},
true
);
});
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
import * as i18n from '../translations';
const ConfigureComponent: CustomFieldType<CaseCustomFieldToggle>['Configure'] = () => {
return (
<>
<UseField
path="options.required"
component={CheckBoxField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'toggle-custom-field-options',
label: i18n.FIELD_OPTION_REQUIRED,
},
label: i18n.FIELD_OPTIONS,
}}
/>
</>
);
};
ConfigureComponent.displayName = 'Configure';
export const Configure = React.memo(ConfigureComponent);

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { configureToggleCustomFieldFactory } from './configure_toggle_field';
describe('configureToggleCustomFieldFactory ', () => {
const builder = configureToggleCustomFieldFactory();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
expect(builder).toEqual({
id: 'toggle',
label: 'Toggle',
build: expect.any(Function),
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CustomFieldFactory } from '../types';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
import { CustomFieldTypes } from '../../../../common/types/domain';
import * as i18n from '../translations';
import { Edit } from './edit';
import { View } from './view';
import { Configure } from './configure';
import { Create } from './create';
export const configureToggleCustomFieldFactory: CustomFieldFactory<CaseCustomFieldToggle> = () => ({
id: CustomFieldTypes.TOGGLE,
label: i18n.TOGGLE_LABEL,
build: () => ({
Configure,
Edit,
View,
Create,
}),
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { FormTestComponent } from '../../../common/test_utils';
import { Create } from './create';
import { customFieldsConfigurationMock } from '../../../containers/mock';
import userEvent from '@testing-library/user-event';
describe('Create ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const customFieldConfiguration = customFieldsConfigurationMock[1];
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={false} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument();
expect(
screen.getByTestId(`${customFieldConfiguration.key}-toggle-create-custom-field`)
).toBeInTheDocument();
expect(screen.getByRole('switch')).not.toBeChecked();
});
it('updates the value correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={false} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
userEvent.click(screen.getByRole('switch'));
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
// data, isValid
expect(onSubmit).toHaveBeenCalledWith(
{
customFields: {
[customFieldConfiguration.key]: true,
},
},
true
);
});
});
it('sets value to false by default', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create
isLoading={false}
customFieldConfiguration={{ ...customFieldConfiguration, required: true }}
/>
</FormTestComponent>
);
userEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{
customFields: {
[customFieldConfiguration.key]: false,
},
},
true
);
});
});
it('disables the toggle when loading', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Create isLoading={true} customFieldConfiguration={customFieldConfiguration} />
</FormTestComponent>
);
expect(screen.getByRole('switch')).toBeDisabled();
});
});

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({
customFieldConfiguration,
isLoading,
}) => {
const { key, label } = customFieldConfiguration;
return (
<UseField
path={`customFields.${key}`}
component={ToggleField}
defaultValue={false}
key={key}
label={label}
componentProps={{
euiFieldProps: {
'data-test-subj': `${key}-toggle-create-custom-field`,
disabled: isLoading,
},
}}
/>
);
};
CreateComponent.displayName = 'Create';
export const Create = React.memo(CreateComponent);

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { FormTestComponent } from '../../../common/test_utils';
import { Edit } from './edit';
import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock';
import userEvent from '@testing-library/user-event';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
describe('Edit ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const customField = customFieldsMock[1] as CaseCustomFieldToggle;
const customFieldConfiguration = customFieldsConfigurationMock[1];
it('renders correctly', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument();
expect(screen.getByTestId('case-toggle-custom-field-test_key_2')).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeChecked();
});
it('calls onSubmit when changing value', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith({ ...customField, value: false });
});
});
it('disables the toggle if the user does not have permissions', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={false}
/>
</FormTestComponent>
);
expect(screen.getByRole('switch')).toBeDisabled();
});
it('disables the toggle when loading', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customField={customField}
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={true}
canUpdate={true}
/>
</FormTestComponent>
);
expect(screen.getByRole('switch')).toBeDisabled();
});
it('sets the configuration key and the initial value if the custom field is undefined', async () => {
render(
<FormTestComponent onSubmit={onSubmit}>
<Edit
customFieldConfiguration={customFieldConfiguration}
onSubmit={onSubmit}
isLoading={false}
canUpdate={true}
/>
</FormTestComponent>
);
userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(onSubmit).toBeCalledWith({
...customField,
key: customFieldConfiguration.key,
/**
* Initial value is false when the custom field is undefined.
* By clicking to the switch it is set to true
*/
value: true,
});
});
});
});

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
import { CustomFieldTypes } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
const EditComponent: CustomFieldType<CaseCustomFieldToggle>['Edit'] = ({
customField,
customFieldConfiguration,
onSubmit,
isLoading,
canUpdate,
}) => {
const initialValue = Boolean(customField?.value);
const title = customFieldConfiguration.label;
const { form } = useForm<{ value: boolean }>({
defaultValue: { value: initialValue },
});
const onSubmitCustomField = async () => {
const { isValid, data } = await form.submit();
if (isValid) {
onSubmit({
...customField,
key: customField?.key ?? customFieldConfiguration.key,
type: CustomFieldTypes.TOGGLE,
value: data.value,
});
}
};
return (
<>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText>
<h4>{title}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup
gutterSize="m"
data-test-subj={`case-toggle-custom-field-${customFieldConfiguration.key}`}
direction="column"
>
<Form form={form}>
<UseField
path="value"
component={ToggleField}
onChange={onSubmitCustomField}
componentProps={{
euiFieldProps: {
disabled: isLoading || !canUpdate,
'data-test-subj': `case-toggle-custom-field-form-field-${customFieldConfiguration.key}`,
},
}}
/>
</Form>
</EuiFlexGroup>
</>
);
};
EditComponent.displayName = 'Edit';
export const Edit = React.memo(EditComponent);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { CustomFieldTypes } from '../../../../common/types/domain';
import { View } from './view';
describe('View ', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const customField = {
type: CustomFieldTypes.TOGGLE as const,
key: 'test_key_1',
value: true,
};
it('renders correctly', async () => {
render(<View customField={customField} />);
expect(screen.getByTestId('toggle-custom-field-view-test_key_1')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIcon } from '@elastic/eui';
import type { CaseCustomFieldToggle } from '../../../../common/types/domain';
import type { CustomFieldType } from '../types';
const ViewComponent: CustomFieldType<CaseCustomFieldToggle>['View'] = ({ customField }) => {
const value = Boolean(customField?.value);
const iconType = value ? 'check' : 'empty';
return (
<EuiIcon data-test-subj={`toggle-custom-field-view-${customField?.key}`} type={iconType}>
{value}
</EuiIcon>
);
};
ViewComponent.displayName = 'View';
export const View = React.memo(ViewComponent);

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export * from '../../common/translations';
export const TITLE = i18n.translate('xpack.cases.customFields.title', {
defaultMessage: 'Custom Fields',
});
export const DESCRIPTION = i18n.translate('xpack.cases.customFields.description', {
defaultMessage: 'Add more optional and required fields for customized case collaboration.',
});
export const NO_CUSTOM_FIELDS = i18n.translate('xpack.cases.customFields.noCustomFields', {
defaultMessage: 'You do not have any fields yet',
});
export const ADD_CUSTOM_FIELD = i18n.translate('xpack.cases.customFields.addCustomField', {
defaultMessage: 'Add field',
});
export const MAX_CUSTOM_FIELD_LIMIT = (maxCustomFields: number) =>
i18n.translate('xpack.cases.customFields.maxCustomFieldLimit', {
values: { maxCustomFields },
defaultMessage: 'Maximum number of {maxCustomFields} custom fields reached.',
});
export const SAVE_FIELD = i18n.translate('xpack.cases.customFields.saveField', {
defaultMessage: 'Save field',
});
export const FIELD_LABEL = i18n.translate('xpack.cases.customFields.fieldLabel', {
defaultMessage: 'Field label',
});
export const FIELD_LABEL_HELP_TEXT = i18n.translate('xpack.cases.customFields.fieldLabelHelpText', {
defaultMessage: '50 characters max',
});
export const TEXT_LABEL = i18n.translate('xpack.cases.customFields.textLabel', {
defaultMessage: 'Text',
});
export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel', {
defaultMessage: 'Toggle',
});
export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', {
defaultMessage: 'Field type',
});
export const FIELD_OPTIONS = i18n.translate('xpack.cases.customFields.fieldOptions', {
defaultMessage: 'Options',
});
export const FIELD_OPTION_REQUIRED = i18n.translate(
'xpack.cases.customFields.fieldOptions.Required',
{
defaultMessage: 'Make this field required',
}
);
export const REQUIRED_FIELD = (fieldName: string): string =>
i18n.translate('xpack.cases.customFields.requiredField', {
values: { fieldName },
defaultMessage: '{fieldName} is required.',
});
export const EDIT_CUSTOM_FIELDS_ARIA_LABEL = (customFieldLabel: string) =>
i18n.translate('xpack.cases.caseView.editCustomFieldsAriaLabel', {
values: { customFieldLabel },
defaultMessage: 'click to edit {customFieldLabel}',
});
export const NO_CUSTOM_FIELD_SET = (customFieldLabel: string) =>
i18n.translate('xpack.cases.caseView.noCustomFieldSet', {
values: { customFieldLabel },
defaultMessage: 'No "{customFieldLabel}" added',
});
export const DELETE_FIELD_TITLE = (fieldName: string) =>
i18n.translate('xpack.cases.customFields.deleteField', {
values: { fieldName },
defaultMessage: 'Delete field "{fieldName}"?',
});
export const DELETE_FIELD_DESCRIPTION = i18n.translate(
'xpack.cases.customFields.deleteFieldDescription',
{
defaultMessage: 'The field will be removed from all cases and data will be lost.',
}
);
export const DELETE = i18n.translate('xpack.cases.customFields.fieldOptions.Delete', {
defaultMessage: 'Delete',
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type React from 'react';
import type { CustomFieldTypes } from '../../../common/types/domain';
import type { CasesConfigurationUICustomField, CaseUICustomField } from '../../containers/types';
export interface CustomFieldType<T extends CaseUICustomField> {
Configure: React.FC;
View: React.FC<{
customField?: T;
}>;
Edit: React.FC<{
customField?: T;
customFieldConfiguration: CasesConfigurationUICustomField;
onSubmit: (customField: T) => void;
isLoading: boolean;
canUpdate: boolean;
}>;
Create: React.FC<{
customFieldConfiguration: CasesConfigurationUICustomField;
isLoading: boolean;
}>;
}
export type CustomFieldFactory<T extends CaseUICustomField> = () => {
id: string;
label: string;
build: () => CustomFieldType<T>;
};
export type CustomFieldBuilderMap = {
readonly [key in CustomFieldTypes]: CustomFieldFactory<CaseUICustomField>;
};

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { addOrReplaceCustomField } from './utils';
import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock';
import { CustomFieldTypes } from '../../../common/types/domain';
import type { CaseUICustomField } from '../../../common/ui';
describe('addOrReplaceCustomField ', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('adds new custom field correctly', async () => {
const fieldToAdd: CaseUICustomField = {
key: 'my_test_key',
type: CustomFieldTypes.TEXT,
value: ['my_test_value'],
};
const res = addOrReplaceCustomField(customFieldsMock, fieldToAdd);
expect(res).toMatchInlineSnapshot(
[...customFieldsMock, fieldToAdd],
`
Array [
Object {
"key": "test_key_1",
"type": "text",
"value": Array [
"My text test value 1",
],
},
Object {
"key": "test_key_2",
"type": "toggle",
"value": true,
},
Object {
"key": "my_test_key",
"type": "text",
"value": Array [
"my_test_value",
],
},
]
`
);
});
it('updates existing custom field correctly', async () => {
const fieldToUpdate = {
...customFieldsMock[0],
field: { value: ['My text test value 1!!!'] },
};
const res = addOrReplaceCustomField(customFieldsMock, fieldToUpdate as CaseUICustomField);
expect(res).toMatchInlineSnapshot(
[{ ...fieldToUpdate }, { ...customFieldsMock[1] }],
`
Array [
Object {
"field": Object {
"value": Array [
"My text test value 1!!!",
],
},
"key": "test_key_1",
"type": "text",
"value": Array [
"My text test value 1",
],
},
Object {
"key": "test_key_2",
"type": "toggle",
"value": true,
},
]
`
);
});
it('adds new custom field configuration correctly', async () => {
const fieldToAdd = {
key: 'my_test_key',
type: CustomFieldTypes.TEXT,
label: 'my_test_label',
required: true,
};
const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToAdd);
expect(res).toMatchInlineSnapshot(
[...customFieldsConfigurationMock, fieldToAdd],
`
Array [
Object {
"key": "test_key_1",
"label": "My test label 1",
"required": true,
"type": "text",
},
Object {
"key": "test_key_2",
"label": "My test label 2",
"required": false,
"type": "toggle",
},
Object {
"key": "my_test_key",
"label": "my_test_label",
"required": true,
"type": "text",
},
]
`
);
});
it('updates existing custom field config correctly', async () => {
const fieldToUpdate = {
...customFieldsConfigurationMock[0],
label: `${customFieldsConfigurationMock[0].label}!!!`,
};
const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToUpdate);
expect(res).toMatchInlineSnapshot(
[{ ...fieldToUpdate }, { ...customFieldsConfigurationMock[1] }],
`
Array [
Object {
"key": "test_key_1",
"label": "My test label 1!!!",
"required": true,
"type": "text",
},
Object {
"key": "test_key_2",
"label": "My test label 2",
"required": false,
"type": "toggle",
},
]
`
);
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const addOrReplaceCustomField = <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;
});
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen } from '@testing-library/dom';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { ExperimentalBadge } from './experimental_badge';
describe('ExperimentalBadge', () => {
let appMockRenderer: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRenderer = createAppMockRenderer();
});
it('renders the experimental badge', () => {
appMockRenderer.render(<ExperimentalBadge />);
expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument();
});
it('renders the title correctly', () => {
appMockRenderer.render(<ExperimentalBadge />);
expect(screen.getByText('Technical preview')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { EuiBetaBadgeProps } from '@elastic/eui';
import { EuiBetaBadge } from '@elastic/eui';
import { css } from '@emotion/react';
import { EXPERIMENTAL_LABEL, EXPERIMENTAL_DESC } from '../../common/translations';
interface Props {
icon?: boolean;
size?: EuiBetaBadgeProps['size'];
}
const ExperimentalBadgeComponent: React.FC<Props> = ({ icon = false, size = 's' }) => {
const props: EuiBetaBadgeProps = {
label: EXPERIMENTAL_LABEL,
size,
...(icon && { iconType: 'beaker' }),
tooltipContent: EXPERIMENTAL_DESC,
tooltipPosition: 'bottom' as const,
'data-test-subj': 'case-experimental-badge',
};
return (
<EuiBetaBadge
css={css`
margin-left: 5px;
`}
{...props}
/>
);
};
ExperimentalBadgeComponent.displayName = 'ExperimentalBadge';
export const ExperimentalBadge = React.memo(ExperimentalBadgeComponent);

View file

@ -23,15 +23,6 @@ export const EDIT_TITLE_ARIA = (title: string) =>
defaultMessage: 'You can edit {title} by clicking',
});
export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.header.badge.experimentalLabel', {
defaultMessage: 'Technical preview',
});
export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.header.badge.experimentalDesc', {
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
});
export const BETA_LABEL = i18n.translate('xpack.cases.header.badge.betaLabel', {
defaultMessage: 'Beta',
});

View file

@ -23,7 +23,6 @@ jest.mock('../../common/navigation/hooks');
describe('Configuration button', () => {
let wrapper: ReactWrapper;
const props: ConfigureCaseButtonProps = {
isDisabled: false,
label: 'My label',
msgTooltip: <></>,
showToolTip: false,

View file

@ -66,7 +66,6 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent);
CaseDetailsLink.displayName = 'CaseDetailsLink';
export interface ConfigureCaseButtonProps {
isDisabled: boolean;
label: string;
msgTooltip: JSX.Element;
showToolTip: boolean;
@ -76,7 +75,6 @@ export interface ConfigureCaseButtonProps {
// TODO: Fix this manually. Issue #123375
// eslint-disable-next-line react/display-name
const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
isDisabled,
label,
msgTooltip,
showToolTip,
@ -98,14 +96,14 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
onClick={navigateToConfigureCasesClick}
href={getConfigureCasesUrl()}
iconType="controlsHorizontal"
isDisabled={isDisabled}
isDisabled={false}
aria-label={label}
data-test-subj="configure-case-button"
>
{label}
</LinkButton>
),
[label, isDisabled, navigateToConfigureCasesClick, getConfigureCasesUrl]
[label, navigateToConfigureCasesClick, getConfigureCasesUrl]
);
return showToolTip ? (

View file

@ -18,6 +18,7 @@ import { createTitleUserActionBuilder } from './title';
import { createCaseUserActionBuilder } from './create_case';
import type { UserActionBuilderMap } from './types';
import { createCategoryUserActionBuilder } from './category';
import { createCustomFieldsUserActionBuilder } from './custom_fields/custom_fields';
export const builderMap: UserActionBuilderMap = {
create_case: createCaseUserActionBuilder,
@ -32,4 +33,5 @@ export const builderMap: UserActionBuilderMap = {
settings: createSettingsUserActionBuilder,
assignees: createAssigneesUserActionBuilder,
category: createCategoryUserActionBuilder,
customFields: createCustomFieldsUserActionBuilder,
};

View file

@ -248,6 +248,7 @@ const getCreateCommentUserAction = ({
export const createCommentUserActionBuilder: UserActionBuilder = ({
appId,
caseData,
casesConfiguration,
userProfiles,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
@ -293,6 +294,7 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({
const commentAction = getCreateCommentUserAction({
appId,
caseData,
casesConfiguration,
userProfiles,
userAction: commentUserAction,
externalReferenceAttachmentTypeRegistry,

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { getUserAction } from '../../../containers/mock';
import { TestProviders } from '../../../common/mock';
import { createCustomFieldsUserActionBuilder } from './custom_fields';
import { getMockBuilderArgs } from '../mock';
import {
CustomFieldTypes,
UserActionActions,
UserActionTypes,
} from '../../../../common/types/domain';
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/navigation/hooks');
describe('createCustomFieldsUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly when a custom field is updated', () => {
const userAction = getUserAction('customFields', UserActionActions.update);
const builder = createCustomFieldsUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(
screen.getByText('changed My test label 1 to "My text test value 1"')
).toBeInTheDocument();
});
it('renders correctly when a custom field is updated to an empty value: null', () => {
const userAction = getUserAction('customFields', UserActionActions.update, {
payload: {
customFields: [
{
type: CustomFieldTypes.TEXT,
key: 'test_key_1',
value: null,
},
],
},
});
const builder = createCustomFieldsUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument();
});
it('renders correctly when a custom field is updated to an empty value: empty array', () => {
const userAction = getUserAction('customFields', UserActionActions.update, {
payload: {
customFields: [
{
type: CustomFieldTypes.TEXT,
key: 'test_key_1',
value: [],
},
],
},
});
const builder = createCustomFieldsUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument();
});
it('renders correctly the label when the configuration is not found', () => {
const userAction = getUserAction('customFields', UserActionActions.update);
const builder = createCustomFieldsUserActionBuilder({
...builderArgs,
userAction,
casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] },
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('changed Unknown to "My text test value 1"')).toBeInTheDocument();
});
it('does not build any user actions if the payload is an empty array', () => {
const userAction = getUserAction('customFields', UserActionActions.update);
const builder = createCustomFieldsUserActionBuilder({
...builderArgs,
userAction: {
...userAction,
type: UserActionTypes.customFields,
payload: { customFields: [] },
},
casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] },
});
const createdUserAction = builder.build();
expect(createdUserAction).toEqual([]);
});
});

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