diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 7a45f92fa46684..ba5d8c800baaa5 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -29,7 +29,11 @@ import { NonEmptyString, paginationSchema, } from '../../../schema'; -import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt } from '../../domain'; +import { + CaseCustomFieldToggleRt, + CustomFieldTextTypeRt, + CaseCustomFieldListRt, +} from '../../domain'; import { CaseRt, CaseSettingsRt, @@ -49,7 +53,11 @@ const CaseCustomFieldTextWithValidationRt = rt.strict({ value: rt.union([CaseCustomFieldTextWithValidationValueRt('value'), rt.null]), }); -const CustomFieldRt = rt.union([CaseCustomFieldTextWithValidationRt, CaseCustomFieldToggleRt]); +const CustomFieldRt = rt.union([ + CaseCustomFieldTextWithValidationRt, + CaseCustomFieldToggleRt, + CaseCustomFieldListRt, +]); export const CaseRequestCustomFieldsRt = limitedArraySchema({ codec: CustomFieldRt, diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index bd2e1f5c11af0f..7d6a67b7a14969 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -18,7 +18,11 @@ import { MAX_TEMPLATE_TAG_LENGTH, } from '../../../constants'; import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; -import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; +import { + CustomFieldTextTypeRt, + CustomFieldToggleTypeRt, + CustomFieldListTypeRt, +} from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; @@ -64,8 +68,35 @@ export const ToggleCustomFieldConfigurationRt = rt.intersection([ ), ]); +export const ListCustomFieldOptionRt = rt.strict({ + label: rt.string, + key: rt.string, +}); + +export const ListCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldListTypeRt }), + CustomFieldConfigurationWithoutTypeRt, + rt.strict({ + options: limitedArraySchema({ + codec: ListCustomFieldOptionRt, + min: 1, + max: 10, + fieldName: 'options', + }), + }), + rt.exact( + rt.partial({ + defaultValue: rt.union([rt.string, rt.null]), + }) + ), +]); + export const CustomFieldsConfigurationRt = limitedArraySchema({ - codec: rt.union([TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt]), + codec: rt.union([ + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, + ListCustomFieldConfigurationRt, + ]), min: 0, max: MAX_CUSTOM_FIELDS_PER_CASE, fieldName: 'customFields', diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 1e4e30c95e381c..6232da4250c9a2 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -8,7 +8,11 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; -import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; +import { + CustomFieldTextTypeRt, + CustomFieldToggleTypeRt, + CustomFieldListTypeRt, +} from '../custom_field/v1'; import { CaseBaseOptionalFieldsRt } from '../case/v1'; export const ClosureTypeRt = rt.union([ @@ -51,9 +55,28 @@ export const ToggleCustomFieldConfigurationRt = rt.intersection([ ), ]); +export const ListCustomFieldOptionRt = rt.strict({ + label: rt.string, + key: rt.string, +}); + +export const ListCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldListTypeRt }), + CustomFieldConfigurationWithoutTypeRt, + rt.strict({ + options: rt.array(ListCustomFieldOptionRt), + }), + rt.exact( + rt.partial({ + defaultValue: rt.union([rt.string, rt.null]), + }) + ), +]); + export const CustomFieldConfigurationRt = rt.union([ TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + ListCustomFieldConfigurationRt, ]); export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); @@ -151,3 +174,8 @@ export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; export type Configurations = rt.TypeOf; +export type ListCustomFieldOption = rt.TypeOf; + +export type TextCustomFieldConfiguration = rt.TypeOf; +export type ListCustomFieldConfiguration = rt.TypeOf; +export type ToggleCustomFieldConfiguration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts index 4878fea326b042..dd0a9f77749315 100644 --- a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts @@ -9,10 +9,12 @@ import * as rt from 'io-ts'; export enum CustomFieldTypes { TEXT = 'text', TOGGLE = 'toggle', + LIST = 'list', } export const CustomFieldTextTypeRt = rt.literal(CustomFieldTypes.TEXT); export const CustomFieldToggleTypeRt = rt.literal(CustomFieldTypes.TOGGLE); +export const CustomFieldListTypeRt = rt.literal(CustomFieldTypes.LIST); const CaseCustomFieldTextRt = rt.strict({ key: rt.string, @@ -26,10 +28,21 @@ export const CaseCustomFieldToggleRt = rt.strict({ value: rt.union([rt.boolean, rt.null]), }); -export const CaseCustomFieldRt = rt.union([CaseCustomFieldTextRt, CaseCustomFieldToggleRt]); +export const CaseCustomFieldListRt = rt.strict({ + key: rt.string, + type: CustomFieldListTypeRt, + value: rt.union([rt.string, rt.null]), +}); + +export const CaseCustomFieldRt = rt.union([ + CaseCustomFieldTextRt, + CaseCustomFieldToggleRt, + CaseCustomFieldListRt, +]); export const CaseCustomFieldsRt = rt.array(CaseCustomFieldRt); export type CaseCustomFields = rt.TypeOf; export type CaseCustomField = rt.TypeOf; export type CaseCustomFieldToggle = rt.TypeOf; export type CaseCustomFieldText = rt.TypeOf; +export type CaseCustomFieldList = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 6d75b30dd119d5..1d659517158b1e 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -31,6 +31,7 @@ import type { PersistableStateAttachment, Configuration, CustomFieldTypes, + CustomFieldsConfiguration, } from '../types/domain'; import type { CasePatchRequest, @@ -189,6 +190,7 @@ export type CaseUser = SnakeToCamelCase; export interface FetchCasesProps extends ApiProps { queryParams?: QueryParams; filterOptions?: FilterOptions; + customFieldsConfiguration: CustomFieldsConfiguration; } export interface ApiProps { diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx index ed5fa488386024..d7a23494d10660 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filter_config/use_custom_fields_filter_config.tsx @@ -96,9 +96,11 @@ export const useCustomFieldsFilterConfig = ({ return { customFieldsFilterConfig: [] }; } - for (const { key: fieldKey, type, label: buttonLabel } of customFields ?? []) { + for (const customFieldConfiguration of customFields ?? []) { + const { key: fieldKey, type, label: buttonLabel } = customFieldConfiguration; if (customFieldsBuilder[type]) { - const { filterOptions: customFieldOptions } = customFieldsBuilder[type](); + const { getFilterOptions } = customFieldsBuilder[type](); + const customFieldOptions = getFilterOptions?.(customFieldConfiguration); if (customFieldOptions) { customFieldsFilterConfig.push( diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index efdc443366886a..6027cffbe10561 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -26,8 +26,8 @@ import { Status } from '@kbn/cases-components/src/status/status'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { ActionConnector } from '../../../common/types/domain'; -import { CaseSeverity } from '../../../common/types/domain'; -import type { CaseUI } from '../../../common/ui/types'; +import { CaseSeverity, CustomFieldTypes } from '../../../common/types/domain'; +import type { CaseUI, CasesConfigurationUICustomField } from '../../../common/ui/types'; import type { CasesColumnSelection } from './types'; import { getEmptyCellValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; @@ -333,9 +333,15 @@ export const useCasesColumns = ({ // we need to extend the columnsDict with the columns of // the customFields - customFields.forEach(({ key, type, label }) => { + customFields.forEach((configuration) => { + const { key, type, label } = configuration; if (type in customFieldsBuilderMap) { - const columnDefinition = customFieldsBuilderMap[type]().getEuiTableColumn({ label }); + const customFieldDefinition = customFieldsBuilderMap[type](); + const euiTableColumnProps = + type === CustomFieldTypes.LIST ? { label, options: configuration.options } : { label }; + const columnDefinition = customFieldDefinition.getEuiTableColumn( + euiTableColumnProps as CasesConfigurationUICustomField + ); columnsDict[key] = { ...columnDefinition, diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx index f11e5826ca91c7..9211b3b9116a00 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx @@ -67,7 +67,7 @@ describe.skip('CustomFields', () => { expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); }); - it('should render as optional fields for text custom fields', async () => { + it('should render as optional fields for text and list custom fields', async () => { appMockRender.render( { ); - expect(await screen.findAllByTestId('form-optional-field-label')).toHaveLength(2); + expect(await screen.findAllByTestId('form-optional-field-label')).toHaveLength(4); }); it('should not set default value when in edit mode', async () => { @@ -115,12 +115,14 @@ describe.skip('CustomFields', () => { const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); - expect(customFields).toHaveLength(4); + expect(customFields).toHaveLength(6); expect(customFields[0]).toHaveTextContent('My test label 1'); expect(customFields[1]).toHaveTextContent('My test label 2'); expect(customFields[2]).toHaveTextContent('My test label 3'); expect(customFields[3]).toHaveTextContent('My test label 4'); + expect(customFields[4]).toHaveTextContent('My test label 5'); + expect(customFields[5]).toHaveTextContent('My test label 6'); }); it('should update the custom fields', async () => { @@ -132,6 +134,7 @@ describe.skip('CustomFields', () => { const textField = customFieldsConfigurationMock[2]; const toggleField = customFieldsConfigurationMock[3]; + const listField = customFieldsConfigurationMock[4]; await userEvent.type( await screen.findByTestId(`${textField.key}-${textField.type}-create-custom-field`), @@ -152,6 +155,7 @@ describe.skip('CustomFields', () => { [customFieldsConfigurationMock[1].key]: customFieldsConfigurationMock[1].defaultValue, [textField.key]: 'hello', [toggleField.key]: true, + [listField.key]: 'option_1', }, }, true diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx index ac162e41a47e40..2e871bc5d6e9a2 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx @@ -230,6 +230,7 @@ describe('CaseFormFields', () => { test_key_1: 'My text test value 1', test_key_2: false, test_key_4: false, + test_key_5: 'option_1', }, }, true @@ -268,6 +269,7 @@ describe('CaseFormFields', () => { test_key_1: 'Test custom filed value', test_key_2: true, test_key_4: false, + test_key_5: 'option_1', }, }, true diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx index 3b9d762137abb3..67d8f8fd057648 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx @@ -89,7 +89,7 @@ describe('Case View Page files tab', () => { exact: false, }); - expect(customFields.length).toBe(4); + expect(customFields.length).toBe(6); expect(await within(customFields[0]).findByRole('heading')).toHaveTextContent( 'My test label 1' @@ -103,6 +103,12 @@ describe('Case View Page files tab', () => { expect(await within(customFields[3]).findByRole('heading')).toHaveTextContent( 'My test label 4' ); + expect(await within(customFields[4]).findByRole('heading')).toHaveTextContent( + 'My test label 5' + ); + expect(await within(customFields[5]).findByRole('heading')).toHaveTextContent( + 'My test label 6' + ); }); it('pass the permissions to custom fields correctly', async () => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx index b3c782f83fb504..4528bba3e2ece4 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -397,7 +397,7 @@ describe('CommonFlyout ', () => { label: 'First custom field', required: true, }, - ], + ] as CustomFieldConfiguration[], }; appMockRender = createAppMockRenderer({ license }); @@ -597,21 +597,13 @@ describe('CommonFlyout ', () => { type: 'text', value: 'this is a sample text!', }, - { - key: 'test_key_2', - type: 'toggle', - value: true, - }, - { - key: 'test_key_3', - type: 'text', - value: null, - }, - { - key: 'test_key_4', - type: 'toggle', - value: false, - }, + ...customFieldsConfigurationMock + .slice(1) + .map(({ key, type, defaultValue, required }) => ({ + key, + type, + value: required ? defaultValue : type === CustomFieldTypes.TOGGLE ? false : null, + })), ], }, }); @@ -691,7 +683,7 @@ describe('CommonFlyout ', () => { label: 'First custom field', required: true, }, - ], + ] as CustomFieldConfiguration[], connector: { id: 'servicenow-1', name: 'My SN connector', diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 6c65eae41c78bc..10c0e95a86bfb6 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -712,11 +712,7 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-user', - customFields: [ - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], + customFields: customFieldsConfigurationMock.slice(1), templates: [], id: '', version: '', @@ -771,11 +767,7 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-user', - customFields: [ - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], + customFields: customFieldsConfigurationMock.slice(1), templates: [ { key: 'test_template_4', @@ -848,26 +840,15 @@ describe('ConfigureCases', () => { name: 'Fourth test template', caseFields: { customFields: [ - { - key: customFieldsConfigurationMock[0].key, - type: customFieldsConfigurationMock[0].type, - value: customFieldsConfigurationMock[0].defaultValue, - }, - { - key: customFieldsConfigurationMock[1].key, - type: customFieldsConfigurationMock[1].type, - value: customFieldsConfigurationMock[1].defaultValue, - }, - { - key: customFieldsConfigurationMock[2].key, - type: customFieldsConfigurationMock[2].type, - value: null, - }, - { - key: customFieldsConfigurationMock[3].key, - type: customFieldsConfigurationMock[3].type, - value: false, - }, + ...customFieldsConfigurationMock.map(({ key, type, defaultValue, required }) => ({ + key, + type, + value: required + ? defaultValue + : type === CustomFieldTypes.TOGGLE + ? false + : null, + })), { key: expect.anything(), type: CustomFieldTypes.TEXT as const, @@ -928,9 +909,7 @@ describe('ConfigureCases', () => { required: !customFieldsConfigurationMock[0].required, defaultValue: customFieldsConfigurationMock[0].defaultValue, }, - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, + ...customFieldsConfigurationMock.slice(1), ], templates: [], id: '', @@ -1087,28 +1066,17 @@ describe('ConfigureCases', () => { settings: { syncAlerts: true, }, - customFields: [ - { - key: customFieldsConfigurationMock[0].key, - type: customFieldsConfigurationMock[0].type, - value: customFieldsConfigurationMock[0].defaultValue, - }, - { - key: customFieldsConfigurationMock[1].key, - type: customFieldsConfigurationMock[1].type, - value: customFieldsConfigurationMock[1].defaultValue, - }, - { - key: customFieldsConfigurationMock[2].key, - type: customFieldsConfigurationMock[2].type, - value: null, - }, - { - key: customFieldsConfigurationMock[3].key, - type: customFieldsConfigurationMock[3].type, - value: false, // when no default value for toggle, we set it to false - }, - ], + customFields: customFieldsConfigurationMock.map( + ({ key, type, defaultValue, required }) => ({ + key, + type, + value: required + ? defaultValue + : type === CustomFieldTypes.TOGGLE + ? false + : null, + }) + ), }, }, ], diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 0f28e6f9db1c2d..29be8ede68c0ed 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -55,6 +55,7 @@ import { AttachmentType, ConnectorTypes, CustomFieldTypes, + type CustomFieldConfiguration, } from '../../../common/types/domain'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CreateCaseFormFieldsProps } from './form_fields'; @@ -496,7 +497,7 @@ describe('Create case', () => { type: CustomFieldTypes.TEXT, label: 'my custom field label', required: false, - }, + } as CustomFieldConfiguration, ], }, ]; @@ -544,6 +545,8 @@ describe('Create case', () => { { ...customFieldsMock[1], value: false }, // toggled the default customFieldsMock[2], { ...customFieldsMock[3], value: false }, + customFieldsMock[4], + customFieldsMock[5], { key: 'my_custom_field_key', type: CustomFieldTypes.TEXT, diff --git a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx index d2ee25d08bfa6c..e1ec5a4023a7eb 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx @@ -9,8 +9,10 @@ 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'; +import { configureListCustomFieldFactory } from './list/configure_list_field'; export const builderMap = Object.freeze({ [CustomFieldTypes.TEXT]: configureTextCustomFieldFactory, [CustomFieldTypes.TOGGLE]: configureToggleCustomFieldFactory, + [CustomFieldTypes.LIST]: configureListCustomFieldFactory, } as const) as CustomFieldBuilderMap; diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx index eaaa0e28747eab..bd30660e810732 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -59,7 +59,8 @@ describe('CustomFieldsList', () => { ) ).toBeInTheDocument(); expect((await screen.findAllByText('Text')).length).toBe(2); - expect((await screen.findAllByText('Required')).length).toBe(2); + expect((await screen.findAllByText('List')).length).toBe(2); + expect((await screen.findAllByText('Required')).length).toBe(3); expect( await screen.findByTestId( `custom-field-${customFieldsConfigurationMock[1].key}-${customFieldsConfigurationMock[1].type}` diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx index fc6c774c20b0c9..38dffc925e48af 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx @@ -12,7 +12,7 @@ import { screen, waitFor } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { customFieldsConfigurationMock } from '../../containers/mock'; -import { CustomFieldTypes } from '../../../common/types/domain'; +import { CustomFieldTypes, type CustomFieldsConfiguration } from '../../../common/types/domain'; import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; import { CustomFields } from '.'; import * as i18n from './translations'; @@ -87,7 +87,7 @@ describe('CustomFields', () => { }); it('shows error when custom fields reaches the limit', async () => { - const generatedMockCustomFields = []; + const generatedMockCustomFields: CustomFieldsConfiguration = []; for (let i = 0; i < 6; i++) { generatedMockCustomFields.push({ diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.tsx index d749a7aba9beac..ce74fb2715198d 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/index.tsx @@ -43,7 +43,7 @@ const CustomFieldsComponent: React.FC = ({ const [error, setError] = useState(false); const onAddCustomField = useCallback(() => { - if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) { + if (customFields.length >= MAX_CUSTOM_FIELDS_PER_CASE && !error) { setError(true); return; } diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.test.tsx new file mode 100644 index 00000000000000..9ddf3af3d04224 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { OptionsField, INITIAL_OPTIONS } from './options_field'; + +describe('Configure', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('options-field')).toBeInTheDocument(); + expect(await screen.findByTestId('options-field-option-label-0')).toBeInTheDocument(); + expect((await screen.findByTestId('options-field-option-label-0')).getAttribute('value')).toBe( + INITIAL_OPTIONS[0].label + ); + }); + + it('adds and removes options correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('options-field')).toBeInTheDocument(); + await userEvent.click(await screen.findByTestId('options-field-option-label-0')); + await userEvent.paste('Value 1'); + await userEvent.click(await screen.findByTestId('options-field-add-option')); + await waitFor(async () => + expect(await screen.findByTestId('options-field-option-label-1')).toBeInTheDocument() + ); + await userEvent.click(await screen.findByTestId('options-field-option-label-1')); + await userEvent.paste('Value 2'); + await userEvent.click(await screen.findByTestId('options-field-remove-option-0')); + // Value 2 should move to index 0 and index 1 should be removed + expect((await screen.findByTestId('options-field-option-label-0')).getAttribute('value')).toBe( + 'Value 2' + ); + await expect(screen.findByTestId('options-field-option-label-1')).rejects.toThrow(); + }); + + it('adds no more than maximum set options', async () => { + render( + + + + ); + + expect(await screen.findByTestId('options-field')).toBeInTheDocument(); + expect(await screen.findByTestId('options-field-option-label-0')).toBeInTheDocument(); + await userEvent.click(await screen.findByTestId('options-field-add-option')); + await userEvent.click(await screen.findByTestId('options-field-add-option')); + await expect(screen.findByTestId('options-field-add-option')).rejects.toThrow(); + await userEvent.click(await screen.findByTestId('options-field-remove-option-0')); + expect(await screen.findByTestId('options-field-add-option')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.tsx new file mode 100644 index 00000000000000..6bf8404c391170 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/components/options_field.tsx @@ -0,0 +1,206 @@ +/* + * 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 { + EuiButtonEmpty, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiPanel, + euiDragDropReorder, +} from '@elastic/eui'; +import type { OnDragEndResponder } from '@hello-pangea/dnd'; +import { + getFieldValidityAndErrorMessage, + type FieldHook, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { isEqual } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { v4 as uuidv4 } from 'uuid'; +import type { ListCustomFieldOption } from '../../../../../common/types/domain'; +import * as i18n from '../../translations'; + +interface Props { + field: FieldHook; + idAria?: string; + [key: string]: unknown; + maxOptions?: number; +} + +type OptionsFieldOption = ListCustomFieldOption & { isFresh?: boolean }; + +export const INITIAL_OPTIONS = [ + { + // Key used to identify the initial default option + // String literal 'default' is not used to avoid confusion in case the user changes the + // default value to a different option, + key: '00000000-0000-0000-0000-000000000000', + label: '', + }, +]; + +const OptionsFieldComponent = ({ field, idAria, maxOptions, ...rest }: Props) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + // Add a state to track if an option has just been created. This is used to auto-focus the input, and to prevent + // any validation errors from appearing until after the user has entered a value or blurred the input + const [freshOption, setFreshOption] = useState(null); + + const currentOptions: OptionsFieldOption[] = useMemo(() => { + const parsedValue = field.value || INITIAL_OPTIONS; + if (freshOption) parsedValue.push(freshOption); + return Array.isArray(parsedValue) ? parsedValue : INITIAL_OPTIONS; + }, [field.value, freshOption]); + + useEffectOnce(() => { + if (!isEqual(currentOptions, INITIAL_OPTIONS)) { + field.setValue(currentOptions); + } + }); + + const onChangeOptionLabel = useCallback( + ({ key, label }: OptionsFieldOption) => { + setFreshOption(null); + const newOptions = currentOptions.map((option) => + key === option.key ? { key, label } : option + ); + field.setValue(newOptions); + }, + [currentOptions, field] + ); + + const onAddOption = useCallback(() => { + if (maxOptions && currentOptions.length >= maxOptions) return; + const newOption = { key: uuidv4(), label: '', isFresh: true }; + setFreshOption(newOption); + }, [maxOptions, currentOptions]); + + const onRemoveOption = useCallback( + (key: string) => { + const newOptions = currentOptions.filter((option) => option.key !== key); + field.setValue(newOptions); + }, + [currentOptions, field] + ); + + const onBlurOption = useCallback( + (option: OptionsFieldOption) => { + if (option.isFresh) { + onChangeOptionLabel(option); + } + }, + [onChangeOptionLabel] + ); + + const onDragEnd = useCallback( + ({ source, destination }) => { + if (source && destination) { + const newOptions = euiDragDropReorder(currentOptions, source.index, destination.index); + field.setValue(newOptions); + } + }, + [currentOptions, field] + ); + + return ( + + + + + {currentOptions.map((option, index) => ( + + {(provided) => ( + + + + + + + + + + onChangeOptionLabel({ key: option.key, label: e.target.value }) + } + onBlur={() => onBlurOption(option)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onBlurOption(option); + onAddOption(); + } + }} + data-test-subj={`options-field-option-label-${index}`} + /> + + {currentOptions.length > 1 && ( + + onRemoveOption(option.key)} + data-test-subj={`options-field-remove-option-${index}`} + /> + + )} + + + )} + + ))} + + + {(!maxOptions || currentOptions.length < maxOptions) && ( + + + + {i18n.ADD_LIST_OPTION} + + + + )} + + + ); +}; + +OptionsFieldComponent.displayName = 'OptionsField'; + +export const OptionsField = React.memo(OptionsFieldComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/config.ts b/x-pack/plugins/cases/public/components/custom_fields/list/config.ts new file mode 100644 index 00000000000000..ffe459788a2152 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/config.ts @@ -0,0 +1,51 @@ +/* + * 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 getListFieldConfig = ({ + required, + label, + defaultValue, +}: { + required: boolean; + label: string; + defaultValue?: string | null; +}): FieldConfig => { + const validators = []; + + if (required) { + validators.push({ + validator: emptyField(REQUIRED_FIELD(label)), + }); + } + + return { + ...(defaultValue && { defaultValue }), + validations: [ + ...validators, + { + validator: ({ value }) => { + if (value == null) { + return; + } + + if (value.length > MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) { + return { + message: MAX_LENGTH_ERROR(label, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH), + }; + } + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/configure.test.tsx new file mode 100644 index 00000000000000..87dc35c3ecf090 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/configure.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { Configure } from './configure'; +import { INITIAL_OPTIONS } from './components/options_field'; + +describe('Configure', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('list-custom-field-values')).toBeInTheDocument(); + expect(await screen.findByTestId('list-custom-field-required')).toBeInTheDocument(); + expect(await screen.findByTestId('list-custom-field-default-value')).toBeInTheDocument(); + }); + + it('updates field options correctly when not required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith({}, true); + }); + }); + + it('updates field options correctly when required', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByTestId('list-custom-field-required')); + await userEvent.click(await screen.findByTestId('options-field-option-label-0')); + await userEvent.paste('Default value'); + await userEvent.selectOptions( + await screen.findByTestId('list-custom-field-default-value'), + await screen.getByRole('option', { name: 'Default value' }) + ); + + await userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + required: true, + defaultValue: INITIAL_OPTIONS[0].key, + options: [{ key: INITIAL_OPTIONS[0].key, label: 'Default value' }], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/configure.tsx new file mode 100644 index 00000000000000..8930699d7f286b --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/configure.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isEqual } from 'lodash'; +import { CheckBoxField, SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField, useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import type { CaseCustomFieldList, ListCustomFieldOption } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import type { CustomFieldType } from '../types'; +import { OptionsField, INITIAL_OPTIONS } from './components/options_field'; +import { getListFieldConfig } from './config'; +import { listCustomFieldOptionsToEuiSelectOptions } from './helpers/list_custom_field_options_to_eui_select_options'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + const config = getListFieldConfig({ + required: false, + label: i18n.DEFAULT_VALUE.toLocaleLowerCase(), + }); + const [currentOptions, setCurrentOptions] = useState(INITIAL_OPTIONS); + const currentEuiSelectOptions = useMemo( + () => listCustomFieldOptionsToEuiSelectOptions(currentOptions), + [currentOptions] + ); + + // On edit, initialize the options so the default value can be displayed + const form = useFormContext(); + const fields = form.getFields(); + useEffect(() => { + if (isEqual(currentOptions, INITIAL_OPTIONS) && fields.options?.value) { + setCurrentOptions(fields.options?.value as ListCustomFieldOption[]); + } + }, [currentOptions, fields]); + + return ( + <> + { + setCurrentOptions(options); + }, [])} + /> + + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.test.ts new file mode 100644 index 00000000000000..9a4fd9cde89b87 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { configureListCustomFieldFactory } from './configure_list_field'; + +describe('configureListCustomFieldFactory ', () => { + const builder = configureListCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'list', + label: 'List', + getEuiTableColumn: expect.any(Function), + build: expect.any(Function), + convertNullToEmpty: expect.any(Function), + getFilterOptions: expect.any(Function), + convertValueToDisplayText: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.ts b/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.ts new file mode 100644 index 00000000000000..1d2eb833963bb3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/configure_list_field.ts @@ -0,0 +1,44 @@ +/* + * 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 { + CaseCustomFieldList, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { getEuiTableColumn } from './get_eui_table_column'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureListCustomFieldFactory: CustomFieldFactory< + CaseCustomFieldList, + ListCustomFieldConfiguration +> = () => ({ + id: CustomFieldTypes.LIST, + label: i18n.LIST_LABEL, + getEuiTableColumn, + build: () => ({ + Configure, + Edit, + View, + Create, + }), + convertNullToEmpty: (value: string | boolean | null) => (value == null ? '' : String(value)), + getFilterOptions: ({ options }) => options.map((option) => ({ ...option, value: option.key })), + convertValueToDisplayText: ( + value: string | null, + configuration: ListCustomFieldConfiguration + ) => { + if (!value) return ''; + const option = configuration.options.find((opt) => opt.key === value); + return option?.label ?? ''; + }, +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/create.test.tsx new file mode 100644 index 00000000000000..e3a957254f079e --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/create.test.tsx @@ -0,0 +1,170 @@ +/* + * 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 type { ListCustomFieldConfiguration } from '../../../../common/types/domain'; +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; + +describe('Create', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // required list custom field with a default value + const customFieldConfiguration = customFieldsConfigurationMock[4] as ListCustomFieldConfiguration; + + it('renders correctly with default values', async () => { + render( + + + + ); + + expect(await screen.findByText(customFieldConfiguration.label)).toBeInTheDocument(); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-list-create-custom-field`) + ).toHaveValue(customFieldConfiguration.defaultValue as string); + }); + + it('renders correctly with optional fields', async () => { + const optionalField = customFieldsConfigurationMock[5] as ListCustomFieldConfiguration; // optional list custom field + + render( + + + + ); + + expect(await screen.findByText(optionalField.label)).toBeInTheDocument(); + expect(await screen.findByTestId(`${optionalField.key}-list-create-custom-field`)).toHaveValue( + '' + ); + }); + + it('does not render default value when setDefaultValue is false', async () => { + render( + + + + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-list-create-custom-field`) + ).toHaveValue(''); + }); + + it('renders loading state correctly', async () => { + render( + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('disables the select field when loading', async () => { + render( + + + + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-list-create-custom-field`) + ).toHaveAttribute('disabled'); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + const listCustomField = await screen.findByTestId( + `${customFieldConfiguration.key}-list-create-custom-field` + ); + + await userEvent.selectOptions( + listCustomField, + await screen.getByRole('option', { name: 'Option 2' }) + ); + + await userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: 'option_2', + }, + }, + true + ); + }); + }); + + it('shows error when selection is required but is empty', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByText('Submit')); + + expect( + await screen.findByText(`${customFieldConfiguration.label} is required.`) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('does not show error when selection is not required but is empty', async () => { + render( + + + + ); + + await userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, true); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/create.tsx new file mode 100644 index 00000000000000..2968b4a34dc15b --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/create.tsx @@ -0,0 +1,59 @@ +/* + * 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 { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useMemo } from 'react'; +import type { + CaseCustomFieldList, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; +import { OptionalFieldLabel } from '../../optional_field_label'; +import type { CustomFieldType } from '../types'; +import { getListFieldConfig } from './config'; +import { listCustomFieldOptionsToEuiSelectOptions } from './helpers/list_custom_field_options_to_eui_select_options'; + +const CreateComponent: CustomFieldType< + CaseCustomFieldList, + ListCustomFieldConfiguration +>['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, setDefaultValue = true }) => { + const { key, label, required, defaultValue, options } = customFieldConfiguration; + + const euiSelectOptions = useMemo( + () => listCustomFieldOptionsToEuiSelectOptions(options), + [options] + ); + + const config = getListFieldConfig({ + required: setAsOptional ? false : required, + label, + ...(defaultValue && setDefaultValue && { defaultValue: String(defaultValue) }), + }); + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/edit.test.tsx new file mode 100644 index 00000000000000..4a03826e789bae --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/edit.test.tsx @@ -0,0 +1,359 @@ +/* + * 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 { + CaseCustomFieldList, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; +import { POPULATED_WITH_DEFAULT } from '../translations'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[4] as CaseCustomFieldList; + const customFieldConfiguration = customFieldsConfigurationMock[4] as ListCustomFieldConfiguration; + it('renders correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('case-list-custom-field-test_key_5')).toBeInTheDocument(); + expect( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ).toBeInTheDocument(); + expect(await screen.findByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(await screen.findByText('Option 1')).toBeInTheDocument(); + }); + + it('does not show the edit button if the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-list-custom-field-edit-button-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('does not show the edit button when loading', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-list-custom-field-edit-button-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('shows the loading spinner when loading', async () => { + render( + + + + ); + + expect( + await screen.findByTestId('case-list-custom-field-loading-test_key_5') + ).toBeInTheDocument(); + }); + + it('shows the no value text if the custom field is undefined', async () => { + render( + + + + ); + + expect(await screen.findByText('No value is added')).toBeInTheDocument(); + }); + + it('uses the required value correctly if a required field is empty', async () => { + render( + + + + ); + + expect(await screen.findByText('No value is added')).toBeInTheDocument(); + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + + expect( + await screen.findByTestId(`case-list-custom-field-form-field-${customFieldConfiguration.key}`) + ).toHaveValue(customFieldConfiguration.defaultValue as string); + expect( + await screen.findByText('This field is populated with the default value.') + ).toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: customFieldConfiguration.defaultValue, + }); + }); + }); + + it('does not show the value when the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.queryByTestId('list-custom-field-view-test_key_5')).not.toBeInTheDocument(); + }); + + it('does not show the value when the value is null', async () => { + render( + + + + ); + + expect(screen.queryByTestId('list-custom-field-view-test_key_5')).not.toBeInTheDocument(); + }); + + it('does not show the form when the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-list-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-list-custom-field-submit-button-test_key_5') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-list-custom-field-cancel-button-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + await userEvent.selectOptions( + await screen.findByTestId('case-list-custom-field-form-field-test_key_5'), + await screen.findByRole('option', { name: 'Option 2' }) + ); + + expect( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: 'option_2', + }); + }); + }); + + it('calls onSubmit with defaultValue if no initialValue exists', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + + expect(await screen.findByText(POPULATED_WITH_DEFAULT)).toBeInTheDocument(); + expect(await screen.findByTestId('case-list-custom-field-form-field-test_key_5')).toHaveValue( + customFieldConfiguration.defaultValue as string + ); + expect( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: customFieldConfiguration.defaultValue, + }); + }); + }); + + it('hides the form when clicking the cancel button', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + + expect( + await screen.findByTestId('case-list-custom-field-form-field-test_key_5') + ).toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-cancel-button-test_key_5') + ); + + expect( + screen.queryByTestId('case-list-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + }); + + it('reset to initial value when canceling', async () => { + render( + + + + ); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + await userEvent.selectOptions( + await screen.findByTestId('case-list-custom-field-form-field-test_key_5'), + await screen.findByRole('option', { name: 'Option 2' }) + ); + + expect( + await screen.findByTestId('case-list-custom-field-submit-button-test_key_5') + ).not.toBeDisabled(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-cancel-button-test_key_5') + ); + + expect( + screen.queryByTestId('case-list-custom-field-form-field-test_key_5') + ).not.toBeInTheDocument(); + + await userEvent.click( + await screen.findByTestId('case-list-custom-field-edit-button-test_key_5') + ); + expect(await screen.findByTestId('case-list-custom-field-form-field-test_key_5')).toHaveValue( + 'option_1' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/edit.tsx new file mode 100644 index 00000000000000..4ac56af0c9a21f --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/edit.tsx @@ -0,0 +1,257 @@ +/* + * 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, useMemo } 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 { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { + useForm, + UseField, + Form, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { + CaseCustomFieldList, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import { View } from './view'; +import { + CANCEL, + EDIT_CUSTOM_FIELDS_ARIA_LABEL, + NO_CUSTOM_FIELD_SET, + SAVE, + POPULATED_WITH_DEFAULT, +} from '../translations'; +import { getListFieldConfig } from './config'; +import { listCustomFieldOptionsToEuiSelectOptions } from './helpers/list_custom_field_options_to_eui_select_options'; + +interface FormState { + value: string; + isValid: boolean | undefined; + submit: FormHook<{ value: string }>['submit']; +} + +interface FormWrapper { + initialValue: string; + isLoading: boolean; + customFieldConfiguration: ListCustomFieldConfiguration; + onChange: (state: FormState) => void; +} + +const FormWrapperComponent: React.FC = ({ + initialValue, + customFieldConfiguration, + isLoading, + onChange, +}) => { + const { form } = useForm<{ value: string }>({ + defaultValue: { + value: + customFieldConfiguration?.defaultValue != null && isEmpty(initialValue) + ? customFieldConfiguration.defaultValue + : initialValue, + }, + }); + const [{ value }] = useFormData({ form }); + const { submit, isValid } = form; + const formFieldConfig = getListFieldConfig({ + required: customFieldConfiguration.required, + label: customFieldConfiguration.label, + }); + const populatedWithDefault = + value === customFieldConfiguration?.defaultValue && isEmpty(initialValue); + + useEffect(() => { + onChange({ + value, + isValid, + submit, + }); + }, [isValid, onChange, submit, value]); + + const selectOptions = useMemo( + () => listCustomFieldOptionsToEuiSelectOptions(customFieldConfiguration.options), + [customFieldConfiguration.options] + ); + + return ( +
+ + + ); +}; + +FormWrapperComponent.displayName = 'FormWrapper'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const selectedOptionExists = customFieldConfiguration.options.find( + (option) => option.key === customField?.value + ); + const initialValue = selectedOptionExists ? customField?.value ?? '' : ''; + + const [isEdit, setIsEdit] = useState(false); + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: { value: '' } }), + value: initialValue, + }); + + 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.LIST, + value, + }); + } + + setIsEdit(false); + }; + + const title = customFieldConfiguration.label; + + const isListFieldValid = + formState.isValid || + (formState.value === customFieldConfiguration.defaultValue && !initialValue); + const isCustomFieldValueDefined = !isEmpty(customField?.value) && selectedOptionExists; + + return ( + <> + + + +

{title}

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

{NO_CUSTOM_FIELD_SET}

+ )} + {!isEdit && isCustomFieldValueDefined && ( + + + + )} + {isEdit && canUpdate && ( + + + + + + + + + {SAVE} + + + + + {CANCEL} + + + + + + )} +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.test.tsx new file mode 100644 index 00000000000000..2a692b784ddb3f --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { screen } from '@testing-library/react'; + +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { getEuiTableColumn } from './get_eui_table_column'; + +describe('getEuiTableColumn ', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + + jest.clearAllMocks(); + }); + + it('returns a name and a render function', async () => { + const label = 'MockLabel'; + const options = [{ key: 'foobar', label: 'MockOption' }]; + + expect(getEuiTableColumn({ label, options })).toEqual({ + name: label, + render: expect.any(Function), + width: '250px', + 'data-test-subj': 'list-custom-field-column', + }); + }); + + it('render function renders a list column correctly', async () => { + const key = 'test_key_1'; + const value = 'foobar'; + const options = [{ key: 'foobar', label: 'MockOption' }]; + + const column = getEuiTableColumn({ label: 'MockLabel', options }); + + appMockRender.render(
{column.render({ key, type: CustomFieldTypes.LIST, value })}
); + + expect(screen.getByTestId(`list-custom-field-column-view-${key}`)).toBeInTheDocument(); + expect(screen.getByTestId(`list-custom-field-column-view-${key}`)).toHaveTextContent( + 'MockOption' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.tsx new file mode 100644 index 00000000000000..386d57003a3f80 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/get_eui_table_column.tsx @@ -0,0 +1,31 @@ +/* + * 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 { + CaseCustomField, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; +import type { CustomFieldEuiTableColumn } from '../types'; + +export const getEuiTableColumn = ({ + label, + options, +}: Pick): CustomFieldEuiTableColumn => ({ + name: label, + width: '250px', + render: (customField: CaseCustomField) => ( +

+ {options.find((option) => option.key === customField.value)?.label ?? null} +

+ ), + 'data-test-subj': 'list-custom-field-column', +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/helpers/list_custom_field_options_to_eui_select_options.ts b/x-pack/plugins/cases/public/components/custom_fields/list/helpers/list_custom_field_options_to_eui_select_options.ts new file mode 100644 index 00000000000000..b0cdcf4a610de9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/helpers/list_custom_field_options_to_eui_select_options.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiSelectOption } from '@elastic/eui'; +import type { ListCustomFieldOption } from '../../../../../common/types/domain'; + +export const listCustomFieldOptionsToEuiSelectOptions = ( + fieldOptions: ListCustomFieldOption[] +): EuiSelectOption[] => + fieldOptions.map((option) => ({ + value: option.key, + text: option.label, + })); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/view.test.tsx new file mode 100644 index 00000000000000..df40674b8335b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/view.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import type { ListCustomFieldConfiguration } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const configuration: ListCustomFieldConfiguration = { + options: [{ key: 'test_option', label: 'My list test value' }], + type: CustomFieldTypes.LIST, + key: 'test_key_1', + label: 'Test list label', + required: false, + }; + + const customField = { + type: CustomFieldTypes.LIST as const, + key: 'test_key_1', + value: 'test_option', + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByText('My list test value')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/list/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/list/view.tsx new file mode 100644 index 00000000000000..996d733f18084f --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/list/view.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import type { + CaseCustomFieldList, + ListCustomFieldConfiguration, +} from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ + customField, + configuration, +}) => { + const displayValue = + configuration?.options.find((option) => option.key === customField?.value)?.label ?? '-'; + return ( + + {displayValue} + + ); +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/schema.tsx b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx index 003e7126ef921f..a20c178931b20c 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/schema.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx @@ -6,8 +6,9 @@ */ import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import * as i18n from './translations'; +import type { ListCustomFieldOption } from '../../../common/types/domain'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; +import * as i18n from './translations'; const { emptyField, maxLengthField } = fieldValidators; @@ -48,4 +49,22 @@ export const schema = { label: i18n.DEFAULT_VALUE, validations: [], }, + options: { + label: i18n.FIELD_OPTIONS, + validations: [ + { + validator: emptyField(i18n.REQUIRED_OPTIONS), + }, + { + validator: ({ value }: { value: ListCustomFieldOption[] }) => { + const hasEmptyValue = value.some((val) => val.label.trim() === ''); + if (hasEmptyValue) { + return { + message: i18n.EMPTY_OPTIONS, + }; + } + }, + }, + ], + }, }; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx index 2ec61a4b805294..6ffd13682eccd2 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx @@ -8,12 +8,18 @@ import React from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { CheckBoxField, TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { + CaseCustomFieldText, + TextCustomFieldConfiguration, +} from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; import { getTextFieldConfig } from './config'; import * as i18n from '../translations'; -const ConfigureComponent: CustomFieldType['Configure'] = () => { +const ConfigureComponent: CustomFieldType< + CaseCustomFieldText, + TextCustomFieldConfiguration +>['Configure'] = () => { const config = getTextFieldConfig({ required: false, label: i18n.DEFAULT_VALUE.toLocaleLowerCase(), diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts index c0f50820d45f3e..f20dadf6cae501 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts @@ -5,7 +5,10 @@ * 2.0. */ import type { CustomFieldFactory } from '../types'; -import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { + CaseCustomFieldText, + TextCustomFieldConfiguration, +} from '../../../../common/types/domain'; import { CustomFieldTypes } from '../../../../common/types/domain'; import * as i18n from '../translations'; @@ -15,7 +18,10 @@ import { View } from './view'; import { Configure } from './configure'; import { Create } from './create'; -export const configureTextCustomFieldFactory: CustomFieldFactory = () => ({ +export const configureTextCustomFieldFactory: CustomFieldFactory< + CaseCustomFieldText, + TextCustomFieldConfiguration +> = () => ({ id: CustomFieldTypes.TEXT, label: i18n.TEXT_LABEL, getEuiTableColumn, diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx index bb01226604d3a5..ce47f0abcf379f 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { TextCustomFieldConfiguration } from '../../../../common/types/domain'; import { FormTestComponent } from '../../../common/test_utils'; import { Create } from './create'; import { customFieldsConfigurationMock } from '../../../containers/mock'; @@ -23,7 +24,7 @@ describe.skip('Create ', () => { }); // required text custom field with a default value - const customFieldConfiguration = customFieldsConfigurationMock[0]; + const customFieldConfiguration = customFieldsConfigurationMock[0] as TextCustomFieldConfiguration; it('renders correctly with default values', async () => { render( @@ -40,7 +41,7 @@ describe.skip('Create ', () => { }); it('renders correctly with optional fields', async () => { - const optionalField = customFieldsConfigurationMock[2]; // optional text custom field + const optionalField = customFieldsConfigurationMock[2] as TextCustomFieldConfiguration; // optional text custom field render( diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx index f735a4034f024e..27f6bcf5bc21a7 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -8,17 +8,18 @@ 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 { + CaseCustomFieldText, + TextCustomFieldConfiguration, +} from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; import { getTextFieldConfig } from './config'; import { OptionalFieldLabel } from '../../optional_field_label'; -const CreateComponent: CustomFieldType['Create'] = ({ - customFieldConfiguration, - isLoading, - setAsOptional, - setDefaultValue = true, -}) => { +const CreateComponent: CustomFieldType< + CaseCustomFieldText, + TextCustomFieldConfiguration +>['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, setDefaultValue = true }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ required: setAsOptional ? false : required, diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.ts similarity index 85% rename from x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts rename to x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.ts index 9a9994db2a3d2d..262ae52a67b0da 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.ts @@ -20,11 +20,9 @@ describe('configureToggleCustomFieldFactory ', () => { label: 'Toggle', getEuiTableColumn: expect.any(Function), build: expect.any(Function), - filterOptions: [ - { key: 'on', label: 'On', value: true }, - { key: 'off', label: 'Off', value: false }, - ], getDefaultValue: expect.any(Function), + convertValueToDisplayText: expect.any(Function), + getFilterOptions: expect.any(Function), }); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts index 0ad2e0fd2a68f1..f35d73380609ae 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts @@ -6,7 +6,10 @@ */ import type { CustomFieldFactory } from '../types'; -import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { + CaseCustomFieldToggle, + ToggleCustomFieldConfiguration, +} from '../../../../common/types/domain'; import { CustomFieldTypes } from '../../../../common/types/domain'; import * as i18n from '../translations'; @@ -16,7 +19,10 @@ import { View } from './view'; import { Configure } from './configure'; import { Create } from './create'; -export const configureToggleCustomFieldFactory: CustomFieldFactory = () => ({ +export const configureToggleCustomFieldFactory: CustomFieldFactory< + CaseCustomFieldToggle, + ToggleCustomFieldConfiguration +> = () => ({ id: CustomFieldTypes.TOGGLE, label: i18n.TOGGLE_LABEL, getEuiTableColumn, @@ -26,9 +32,11 @@ export const configureToggleCustomFieldFactory: CustomFieldFactory [ { key: 'on', label: i18n.TOGGLE_FIELD_ON_LABEL, value: true }, { key: 'off', label: i18n.TOGGLE_FIELD_OFF_LABEL, value: false }, ], getDefaultValue: () => false, + convertValueToDisplayText: (value) => + value ? i18n.TOGGLE_FIELD_ON_LABEL : i18n.TOGGLE_FIELD_OFF_LABEL, }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx index 8f0bda74d0208e..4dd1eb9b498639 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -8,20 +8,22 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; +import type { ToggleCustomFieldConfiguration } from '../../../../common/types/domain'; import { FormTestComponent } from '../../../common/test_utils'; import { Create } from './create'; import { customFieldsConfigurationMock } from '../../../containers/mock'; import userEvent from '@testing-library/user-event'; // FLAKY: https://github.com/elastic/kibana/issues/177304 -describe.skip('Create ', () => { +describe.skip('Create', () => { const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); - const customFieldConfiguration = customFieldsConfigurationMock[1]; + const customFieldConfiguration = + customFieldsConfigurationMock[1] as ToggleCustomFieldConfiguration; it('renders correctly with required and defaultValue', async () => { render( diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx index eb3ad2b114e579..5220e8d39dd4fb 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx @@ -8,14 +8,16 @@ 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 { + CaseCustomFieldToggle, + ToggleCustomFieldConfiguration, +} from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; -const CreateComponent: CustomFieldType['Create'] = ({ - customFieldConfiguration, - isLoading, - setDefaultValue = true, -}) => { +const CreateComponent: CustomFieldType< + CaseCustomFieldToggle, + ToggleCustomFieldConfiguration +>['Create'] = ({ customFieldConfiguration, isLoading, setDefaultValue = true }) => { const { key, label, defaultValue } = customFieldConfiguration; return ( diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts index 5f1a91765193fd..d4d0f3e84e3b7c 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/translations.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -51,6 +51,10 @@ export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel defaultMessage: 'Toggle', }); +export const LIST_LABEL = i18n.translate('xpack.cases.customFields.listLabel', { + defaultMessage: 'List', +}); + export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', { defaultMessage: 'Field type', }); @@ -80,6 +84,14 @@ export const REQUIRED_FIELD = (fieldName: string): string => defaultMessage: '{fieldName} is required.', }); +export const REQUIRED_OPTIONS = i18n.translate('xpack.cases.customFields.requiredOptions', { + defaultMessage: 'At least one option is required.', +}); + +export const EMPTY_OPTIONS = i18n.translate('xpack.cases.customFields.emptyOptions', { + defaultMessage: 'All options must have a value.', +}); + export const EDIT_CUSTOM_FIELDS_ARIA_LABEL = (customFieldLabel: string) => i18n.translate('xpack.cases.caseView.editCustomFieldsAriaLabel', { values: { customFieldLabel }, @@ -127,3 +139,14 @@ export const POPULATED_WITH_DEFAULT = i18n.translate( defaultMessage: 'This field is populated with the default value.', } ); + +export const ADD_LIST_OPTION = i18n.translate('xpack.cases.customFields.addListOption', { + defaultMessage: 'Add option', +}); + +export const LIST_OPTION_PLACEHOLDER_TEXT = i18n.translate( + 'xpack.cases.customFields.listOptionPlaceholderText', + { + defaultMessage: 'Option text', + } +); diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts index 70caeabd8edd26..92476b5fb48b3c 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -15,30 +15,31 @@ import type { CaseUICustomField, } from '../../containers/types'; -export interface CustomFieldType { +export interface CustomFieldType { Configure: React.FC; View: React.FC<{ customField?: T; + configuration?: I; }>; Edit: React.FC<{ customField?: T; - customFieldConfiguration: CasesConfigurationUICustomField; + customFieldConfiguration: I; onSubmit: (customField: T) => void; isLoading: boolean; canUpdate: boolean; }>; Create: React.FC<{ - customFieldConfiguration: CasesConfigurationUICustomField; + customFieldConfiguration: I; isLoading: boolean; setAsOptional?: boolean; setDefaultValue?: boolean; }>; } -export interface CustomFieldFactoryFilterOption { +export interface CustomFieldFactoryFilterOption { key: string; label: string; - value: boolean | null; + value: T['value']; } export type CustomFieldEuiTableColumn = Pick< @@ -48,14 +49,18 @@ export type CustomFieldEuiTableColumn = Pick< render: (customField: CaseCustomField) => React.ReactNode; }; -export type CustomFieldFactory = () => { +export type CustomFieldFactory< + T extends CaseUICustomField, + I = CasesConfigurationUICustomField +> = () => { id: string; label: string; - getEuiTableColumn: (params: { label: string }) => CustomFieldEuiTableColumn; - build: () => CustomFieldType; - filterOptions?: CustomFieldFactoryFilterOption[]; + getEuiTableColumn: (params: I) => CustomFieldEuiTableColumn; + build: () => CustomFieldType; + getFilterOptions?: (configuration: I) => Array>; getDefaultValue?: () => string | boolean | null; convertNullToEmpty?: (value: string | boolean | null) => string; + convertValueToDisplayText?: (value: T['value'], configuration: I) => string; }; export type CustomFieldBuilderMap = { diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx index bf5f66aaa3e21d..f9199df7d2d94c 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -589,7 +589,7 @@ describe('TemplateForm', () => { expect( await within(customFieldsElement).findAllByTestId('form-optional-field-label') ).toHaveLength( - customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length + customFieldsConfigurationMock.filter((field) => field.type !== CustomFieldTypes.TOGGLE).length ); const textField = customFieldsConfigurationMock[0]; diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx index 75cfa58e8d5f86..5eebffd431c8b9 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -336,6 +336,7 @@ describe('form fields', () => { test_key_1: 'My text test value 1', test_key_2: false, test_key_4: false, + test_key_5: 'option_1', }, syncAlerts: true, templateTags: [], diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx index b496fc28b133be..4c95813a5749a0 100644 --- a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx @@ -128,6 +128,35 @@ describe('createCustomFieldsUserActionBuilder ', () => { expect(screen.getByText('changed Unknown to "My text test value 1"')).toBeInTheDocument(); }); + it('renders the label of a field with a custom convertValueToDisplayText function', () => { + const userAction = getUserAction('customFields', UserActionActions.update, { + payload: { + customFields: [ + { + type: CustomFieldTypes.TOGGLE, + key: 'test_key_2', + value: true, + }, + ], + }, + }); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed My test label 2 to "On"')).toBeInTheDocument(); + }); + it('does not build any user actions if the payload is an empty array', () => { const userAction = getUserAction('customFields', UserActionActions.update); diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx index 8f006ec1570830..ba12dba6948fb7 100644 --- a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { builderMap as customFieldsBuilder } from '../../custom_fields/builder'; import type { CasesConfigurationUICustomField, CaseUICustomField } from '../../../../common/ui'; import type { SnakeToCamelCase } from '../../../../common/types'; import type { CustomFieldsUserAction } from '../../../../common/types/domain'; @@ -28,7 +29,12 @@ const getLabelTitle = ( const value = Array.isArray(customFieldValue) ? customFieldValue[0] : customFieldValue; - return `${i18n.CHANGED_FIELD.toLowerCase()} ${label} ${i18n.TO} "${value}"`; + const { convertValueToDisplayText } = customFieldsBuilder[customField.type](); + const displayValue = customFieldConfiguration + ? convertValueToDisplayText?.(value, customFieldConfiguration) ?? value + : value; + + return `${i18n.CHANGED_FIELD.toLowerCase()} ${label} ${i18n.TO} "${displayValue}"`; }; export const createCustomFieldsUserActionBuilder: UserActionBuilder = ({ diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 005f15b78b3d76..76d28cd871e82c 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -554,34 +554,44 @@ describe('Utils', () => { expect(res).toMatchInlineSnapshot( [...customFieldsMock, fieldToAdd], ` - Array [ - Object { - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - Object { - "key": "my_test_key", - "type": "text", - "value": "my_test_value", - }, - ] - ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + Object { + "key": "test_key_5", + "type": "list", + "value": "option_1", + }, + Object { + "key": "test_key_6", + "type": "list", + "value": null, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": "my_test_value", + }, + ] + ` ); }); @@ -592,43 +602,45 @@ describe('Utils', () => { }; const res = addOrReplaceField(customFieldsMock, fieldToUpdate as CaseUICustomField); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsMock[1] }, - { ...customFieldsMock[2] }, - { ...customFieldsMock[3] }, - ], - ` - Array [ - Object { - "field": Object { - "value": Array [ - "My text test value 1!!!", - ], - }, - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - ] - ` - ); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "field": Object { + "value": Array [ + "My text test value 1!!!", + ], + }, + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + Object { + "key": "test_key_5", + "type": "list", + "value": "option_1", + }, + Object { + "key": "test_key_6", + "type": "list", + "value": null, + }, + ] + `); }); it('adds new custom field configuration correctly', async () => { @@ -642,41 +654,74 @@ describe('Utils', () => { expect(res).toMatchInlineSnapshot( [...customFieldsConfigurationMock, fieldToAdd], ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - Object { - "key": "my_test_key", - "label": "my_test_label", - "required": true, - "type": "text", - }, - ] - ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + Object { + "defaultValue": "option_1", + "key": "test_key_5", + "label": "My test label 5", + "options": Array [ + Object { + "key": "option_1", + "label": "Option 1", + }, + Object { + "key": "option_2", + "label": "Option 2", + }, + ], + "required": true, + "type": "list", + }, + Object { + "key": "test_key_6", + "label": "My test label 6", + "options": Array [ + Object { + "key": "option_1", + "label": "Option 1", + }, + Object { + "key": "option_2", + "label": "Option 2", + }, + ], + "required": false, + "type": "list", + }, + Object { + "key": "my_test_key", + "label": "my_test_label", + "required": true, + "type": "text", + }, + ] + ` ); }); @@ -687,44 +732,69 @@ describe('Utils', () => { }; const res = addOrReplaceField(customFieldsConfigurationMock, fieldToUpdate); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1!!!", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - ] - ` - ); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1!!!", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + Object { + "defaultValue": "option_1", + "key": "test_key_5", + "label": "My test label 5", + "options": Array [ + Object { + "key": "option_1", + "label": "Option 1", + }, + Object { + "key": "option_2", + "label": "Option 2", + }, + ], + "required": true, + "type": "list", + }, + Object { + "key": "test_key_6", + "label": "My test label 6", + "options": Array [ + Object { + "key": "option_1", + "label": "Option 1", + }, + Object { + "key": "option_2", + "label": "Option 2", + }, + ], + "required": false, + "type": "list", + }, + ] + `); }); }); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 02d639b790885c..6f3f05ccad9f14 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -73,7 +73,7 @@ import { getCasesStatus } from '../api'; import { getCaseConnectorsMockResponse } from '../common/mock/connectors'; import { set } from '@kbn/safer-lodash-set'; import { cloneDeep, omit } from 'lodash'; -import type { CaseUserActionTypeWithAll } from './types'; +import type { CaseUserActionTypeWithAll, CasesConfigurationUICustomField } from './types'; import { CaseSeverity, CaseStatuses, @@ -89,6 +89,29 @@ const fetchMock = jest.fn(); const postMock = jest.fn(); mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock, post: postMock } }); +const apiCustomFieldsConfigurationMock: CasesConfigurationUICustomField[] = [ + { + type: CustomFieldTypes.TOGGLE, + key: 'activeCustomFieldKey', + label: 'Active custom field', + required: true, + defaultValue: true, + }, + { + type: CustomFieldTypes.TOGGLE, + key: 'inactiveCustomFieldKey', + label: 'Active custom field', + required: true, + defaultValue: false, + }, + { + type: CustomFieldTypes.TOGGLE, + key: 'emptyCustomFieldKey', + label: 'Active custom field', + required: false, + }, +]; + describe('Cases API', () => { describe('deleteCases', () => { beforeEach(() => { @@ -214,6 +237,7 @@ describe('Cases API', () => { filterOptions: DEFAULT_FILTER_OPTIONS, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -242,6 +266,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -269,6 +294,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -290,6 +316,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -310,6 +337,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -331,6 +359,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -351,6 +380,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -371,6 +401,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -392,6 +423,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -420,6 +452,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -444,6 +477,7 @@ describe('Cases API', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(resp).toEqual({ ...allCases }); }); @@ -456,6 +490,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -489,6 +524,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { @@ -518,6 +554,7 @@ describe('Cases API', () => { }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: apiCustomFieldsConfigurationMock, }); expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/_search`, { diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 020a4629552f48..07e6c6578c3d1d 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -274,6 +274,7 @@ export const getCases = async ({ sortOrder: 'desc', }, signal, + customFieldsConfiguration, }: FetchCasesProps): Promise => { const body = { ...removeOptionFromFilter({ @@ -293,7 +294,7 @@ export const getCases = async ({ ...(filterOptions.searchFields.length > 0 ? { searchFields: filterOptions.searchFields } : {}), ...(filterOptions.owner.length > 0 ? { owner: filterOptions.owner } : {}), ...(filterOptions.category.length > 0 ? { category: filterOptions.category } : {}), - ...constructCustomFieldsFilter(filterOptions.customFields), + ...constructCustomFieldsFilter(filterOptions.customFields, customFieldsConfiguration), ...queryParams, }; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 8d2feca6b9be0f..bc64214a202bb2 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -1158,6 +1158,8 @@ export const customFieldsMock: CaseUICustomField[] = [ { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, { type: CustomFieldTypes.TEXT, key: 'test_key_3', value: null }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', value: null }, + { type: CustomFieldTypes.LIST, key: 'test_key_5', value: 'option_1' }, + { type: CustomFieldTypes.LIST, key: 'test_key_6', value: null }, ]; export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = [ @@ -1177,6 +1179,27 @@ export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = }, { type: CustomFieldTypes.TEXT, key: 'test_key_3', label: 'My test label 3', required: false }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', label: 'My test label 4', required: false }, + { + type: CustomFieldTypes.LIST, + key: 'test_key_5', + label: 'My test label 5', + required: true, + defaultValue: 'option_1', + options: [ + { key: 'option_1', label: 'Option 1' }, + { key: 'option_2', label: 'Option 2' }, + ], + }, + { + type: CustomFieldTypes.LIST, + key: 'test_key_6', + label: 'My test label 6', + required: false, + options: [ + { key: 'option_1', label: 'Option 1' }, + { key: 'option_2', label: 'Option 2' }, + ], + }, ]; export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index 53900a6920f20a..0c697322860b03 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -16,6 +16,9 @@ import { OWNERS } from '../../common/constants'; jest.mock('./api'); jest.mock('../common/lib/kibana/hooks'); +jest.mock('./configure/use_get_case_configuration', () => ({ + useGetCaseConfiguration: jest.fn().mockReturnValue({ data: { customFields: [] } }), +})); describe('useGetCases', () => { const abortCtrl = new AbortController(); @@ -43,6 +46,7 @@ describe('useGetCases', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['securitySolution'] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: [], }); }); @@ -102,6 +106,7 @@ describe('useGetCases', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [...OWNERS] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: [], }); }); @@ -121,6 +126,7 @@ describe('useGetCases', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['cases'] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: [], }); }); @@ -140,6 +146,7 @@ describe('useGetCases', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['observability'] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: [], }); }); @@ -159,6 +166,7 @@ describe('useGetCases', () => { filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: ['my-owner'] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, + customFieldsConfiguration: [], }); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index 327f1a99cbe9b4..2e3fe5e98368ff 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -16,6 +16,7 @@ import type { ServerError } from '../types'; import { useCasesContext } from '../components/cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../components/app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../utils/permissions'; +import { useGetCaseConfiguration } from './configure/use_get_case_configuration'; export const initialData: CasesFindResponseUI = { cases: [], @@ -35,6 +36,9 @@ export const useGetCases = ( ): UseQueryResult => { const toasts = useToasts(); const { owner } = useCasesContext(); + const { + data: { customFields }, + } = useGetCaseConfiguration(); const availableSolutions = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const hasOwner = !!owner.length; @@ -59,6 +63,7 @@ export const useGetCases = ( ...(params.queryParams ?? {}), }, signal, + customFieldsConfiguration: customFields, }); }, { diff --git a/x-pack/plugins/cases/public/containers/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts index bb784686df6f1f..2d938dabc97fa3 100644 --- a/x-pack/plugins/cases/public/containers/utils.test.ts +++ b/x-pack/plugins/cases/public/containers/utils.test.ts @@ -15,7 +15,7 @@ import { } from './utils'; import type { CaseUI } from './types'; -import { CustomFieldTypes } from '../../common/types/domain'; +import { CustomFieldTypes, type CustomFieldsConfiguration } from '../../common/types/domain'; const caseBeforeUpdate = { comments: [ @@ -186,30 +186,59 @@ describe('utils', () => { }); describe('constructCustomFieldsFilter', () => { + const customFieldsConfigurationMock: CustomFieldsConfiguration = [ + { + key: '957846f4-a792-45a2-bc9a-c028973dfdde', + label: 'Test Toggle 1', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed', + label: 'Test Toggle 2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'c1f0c0a0-2aaf-11ec-8d3d-0242ac130003', + label: 'Test Toggle 3', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4', + label: 'Test Toggle 4', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; it('returns an empty object if the customFields is empty', () => { - expect(constructCustomFieldsFilter({})).toEqual({}); + expect(constructCustomFieldsFilter({}, customFieldsConfigurationMock)).toEqual({}); }); it('returns the customFields correctly', () => { expect( - constructCustomFieldsFilter({ - '957846f4-a792-45a2-bc9a-c028973dfdde': { - type: CustomFieldTypes.TOGGLE, - options: ['on'], - }, - 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': { - type: CustomFieldTypes.TOGGLE, - options: ['off'], - }, - 'c1f0c0a0-2aaf-11ec-8d3d-0242ac130003': { - type: CustomFieldTypes.TOGGLE, - options: [], - }, - 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4': { - type: CustomFieldTypes.TOGGLE, - options: ['on', 'off'], + constructCustomFieldsFilter( + { + '957846f4-a792-45a2-bc9a-c028973dfdde': { + type: CustomFieldTypes.TOGGLE, + options: ['on'], + }, + 'dbeb8e9c-240b-4adb-b83e-e645e86c07ed': { + type: CustomFieldTypes.TOGGLE, + options: ['off'], + }, + 'c1f0c0a0-2aaf-11ec-8d3d-0242ac130003': { + type: CustomFieldTypes.TOGGLE, + options: [], + }, + 'e0e8c50a-8d65-4f00-b6f0-d8a131fd34b4': { + type: CustomFieldTypes.TOGGLE, + options: ['on', 'off'], + }, }, - }) + customFieldsConfigurationMock + ) ).toEqual({ customFields: { '957846f4-a792-45a2-bc9a-c028973dfdde': [true], diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index a00e2e3b738a03..0da13ee64e28d5 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -36,12 +36,13 @@ import type { Cases, Configuration, Configurations, + CustomFieldsConfiguration, User, UserActions, } from '../../common/types/domain'; import { NO_ASSIGNEES_FILTERING_KEYWORD } from '../../common/constants'; import { throwErrors } from '../../common/api'; -import type { CaseUI, FilterOptions, UpdateByKey } from './types'; +import type { CaseUI, CaseUICustomField, FilterOptions, UpdateByKey } from './types'; import * as i18n from './translations'; import type { CustomFieldFactoryFilterOption } from '../components/custom_fields/types'; @@ -173,20 +174,23 @@ export const constructReportersFilter = (reporters: User[]) => { }; export const constructCustomFieldsFilter = ( - optionKeysByCustomFieldKey: FilterOptions['customFields'] + optionKeysByCustomFieldKey: FilterOptions['customFields'], + customFieldsConfiguration: CustomFieldsConfiguration ) => { if (!optionKeysByCustomFieldKey || Object.keys(optionKeysByCustomFieldKey).length === 0) { return {}; } const valuesByCustomFieldKey: { - [key in string]: Array; + [key in string]: Array['value']>; } = {}; for (const [customFieldKey, customField] of Object.entries(optionKeysByCustomFieldKey)) { const { type, options: selectedOptions } = customField; - if (customFieldsBuilder[type]) { - const { filterOptions: customFieldFilterOptionsConfig = [] } = customFieldsBuilder[type](); + const configuration = customFieldsConfiguration.find((config) => config.key === customFieldKey); + if (customFieldsBuilder[type] && configuration) { + const { getFilterOptions } = customFieldsBuilder[type](); + const customFieldFilterOptionsConfig = getFilterOptions?.(configuration) ?? []; const values = selectedOptions .map((selectedOption) => { const filterOptionConfig = customFieldFilterOptionsConfig.find( @@ -194,7 +198,9 @@ export const constructCustomFieldsFilter = ( ); return filterOptionConfig ? filterOptionConfig.value : undefined; }) - .filter((option) => option !== undefined) as Array; + .filter((option) => option !== undefined) as Array< + CustomFieldFactoryFilterOption['value'] + >; if (values.length > 0) { valuesByCustomFieldKey[customFieldKey] = values; diff --git a/x-pack/plugins/cases/server/client/cases/validators.test.ts b/x-pack/plugins/cases/server/client/cases/validators.test.ts index 3e23a9993e9341..aaaf05a3e902f9 100644 --- a/x-pack/plugins/cases/server/client/cases/validators.test.ts +++ b/x-pack/plugins/cases/server/client/cases/validators.test.ts @@ -590,7 +590,7 @@ describe('validators', () => { }; it('does not throw when custom fields are correct', () => { - const newConfig = [ + const newConfig: CustomFieldsConfiguration = [ ...customFieldsConfiguration, { key: 'third_key', diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index aab8937591f9ea..4f1009c10d6dab 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -18,7 +18,10 @@ import { MAX_TEMPLATES_LENGTH, } from '../../../common/constants'; import { ConnectorTypes } from '../../../common'; -import type { TemplatesConfiguration } from '../../../common/types/domain'; +import type { + CustomFieldsConfiguration, + TemplatesConfiguration, +} from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import type { ConfigurationRequest } from '../../../common/types/api'; @@ -1314,7 +1317,7 @@ describe('client', () => { describe('customFields', () => { it('does not throw error when creating template with correct custom fields', async () => { - const customFields = [ + const customFields: CustomFieldsConfiguration = [ { key: 'custom_field_key_1', type: CustomFieldTypes.TEXT, diff --git a/x-pack/plugins/cases/server/connectors/cases/constants.ts b/x-pack/plugins/cases/server/connectors/cases/constants.ts index fafd1a3e0eaeb9..787baddeddc1c2 100644 --- a/x-pack/plugins/cases/server/connectors/cases/constants.ts +++ b/x-pack/plugins/cases/server/connectors/cases/constants.ts @@ -16,4 +16,5 @@ export const VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS: Record = { [CustomFieldTypes.TEXT]: getCasesTextCustomField(), [CustomFieldTypes.TOGGLE]: getCasesToggleCustomField(), + [CustomFieldTypes.LIST]: getCasesListCustomField(), }; export const casesCustomFields: CasesCustomFieldsMap = { diff --git a/x-pack/plugins/cases/server/custom_fields/list.ts b/x-pack/plugins/cases/server/custom_fields/list.ts new file mode 100644 index 00000000000000..ce8327b02efed7 --- /dev/null +++ b/x-pack/plugins/cases/server/custom_fields/list.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { isString } from 'lodash'; +import type { FilteringValues } from './types'; + +export const getCasesListCustomField = () => ({ + isFilterable: true, + isSortable: false, + savedObjectMappingType: 'string', + validateFilteringValues: (values: FilteringValues) => { + values.forEach((value) => { + if (value !== null && !isString(value)) { + throw Boom.badRequest(`Unsupported filtering value for custom field of type list.`); + } + }); + }, + getDefaultValue: () => null, +}); diff --git a/x-pack/plugins/cases/server/custom_fields/text.ts b/x-pack/plugins/cases/server/custom_fields/text.ts index 9a627dce109cc0..326882470cf702 100644 --- a/x-pack/plugins/cases/server/custom_fields/text.ts +++ b/x-pack/plugins/cases/server/custom_fields/text.ts @@ -7,12 +7,13 @@ import Boom from '@hapi/boom'; import { isString } from 'lodash'; +import type { FilteringValues } from './types'; export const getCasesTextCustomField = () => ({ isFilterable: false, isSortable: false, savedObjectMappingType: 'string', - validateFilteringValues: (values: Array) => { + validateFilteringValues: (values: FilteringValues) => { values.forEach((value) => { if (value !== null && !isString(value)) { throw Boom.badRequest(`Unsupported filtering value for custom field of type text.`); diff --git a/x-pack/plugins/cases/server/custom_fields/toggle.ts b/x-pack/plugins/cases/server/custom_fields/toggle.ts index 512e226badaf5a..4fff5f15b8a272 100644 --- a/x-pack/plugins/cases/server/custom_fields/toggle.ts +++ b/x-pack/plugins/cases/server/custom_fields/toggle.ts @@ -7,12 +7,13 @@ import Boom from '@hapi/boom'; import { isBoolean } from 'lodash'; +import type { FilteringValues } from './types'; export const getCasesToggleCustomField = () => ({ isFilterable: true, isSortable: false, savedObjectMappingType: 'boolean', - validateFilteringValues: (values: Array) => { + validateFilteringValues: (values: FilteringValues) => { values.forEach((value) => { if (value !== null && !isBoolean(value)) { throw Boom.badRequest(`Unsupported filtering value for custom field of type toggle.`); diff --git a/x-pack/plugins/cases/server/custom_fields/types.ts b/x-pack/plugins/cases/server/custom_fields/types.ts index 0999ccb4b90c49..c744fb6b021e6e 100644 --- a/x-pack/plugins/cases/server/custom_fields/types.ts +++ b/x-pack/plugins/cases/server/custom_fields/types.ts @@ -11,10 +11,12 @@ export interface ICasesCustomField { isFilterable: boolean; isSortable: boolean; savedObjectMappingType: string; - validateFilteringValues: (values: Array) => void; - getDefaultValue?: () => boolean | string | null; + validateFilteringValues: (values: FilteringValues) => void; + getDefaultValue?: () => boolean | string | null | string[]; } +export type FilteringValues = Array; + export interface CasesCustomFieldsMap { get: (type: CustomFieldTypes) => ICasesCustomField | null; } diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index 05719a5d02ed08..147d566ab94bb6 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -205,7 +205,7 @@ export default ({ getService }: FtrProviderContext): void => { const newConfiguration = await updateConfiguration(supertest, configuration.id, { version: configuration.version, - customFields: customFieldsConfiguration, + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], templates: mockTemplates as ConfigurationPatchRequest['templates'], }); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts index f1b4e4ea8485a9..68a59a4e23faeb 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/list_view.ts @@ -11,6 +11,7 @@ import { CaseSeverity, CaseStatuses, CustomFieldTypes, + CustomFieldsConfiguration, } from '@kbn/cases-plugin/common/types/domain'; import { UserProfile } from '@kbn/user-profile-components'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -288,7 +289,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const caseTitle = 'matchme'; let caseIds: string[] = []; const profiles: UserProfile[] = []; - const customFields = [ + const customFields: CustomFieldsConfiguration = [ { key: 'my_field_01', label: 'My field',