mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
41e54e7208
commit
8da8c475f9
177 changed files with 11205 additions and 1788 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }),
|
||||
]);
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
35
x-pack/plugins/cases/common/types/domain/custom_field/v1.ts
Normal file
35
x-pack/plugins/cases/common/types/domain/custom_field/v1.ts
Normal 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>;
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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>>;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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.',
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 : ''}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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}',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -28,6 +28,7 @@ export const sampleData: CasePostRequest = {
|
|||
},
|
||||
owner: SECURITY_SOLUTION_OWNER,
|
||||
assignees: [],
|
||||
customFields: [],
|
||||
category: null,
|
||||
};
|
||||
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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');
|
||||
});
|
||||
});
|
106
x-pack/plugins/cases/public/components/custom_fields/flyout.tsx
Normal file
106
x-pack/plugins/cases/public/components/custom_fields/flyout.tsx
Normal 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);
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
130
x-pack/plugins/cases/public/components/custom_fields/index.tsx
Normal file
130
x-pack/plugins/cases/public/components/custom_fields/index.tsx
Normal 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);
|
|
@ -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)),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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',
|
||||
});
|
|
@ -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>;
|
||||
};
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue