From d1debc52121b205119212d0ad83bd799e6091181 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 20 Sep 2023 11:50:38 +0200 Subject: [PATCH] [Cases] added UI to add custom field on configuration page (text and toggle field) (#166483) ## Summary Connected to https://github.com/elastic/kibana/issues/160236 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas --- .../cases/public/common/translations.ts | 2 +- .../configure_cases/__mock__/index.tsx | 3 + .../components/configure_cases/index.test.tsx | 136 +++++++++++++++++- .../components/configure_cases/index.tsx | 67 ++++++++- .../custom_fields/add_field_flyout.test.tsx | 106 ++++++++++++++ .../custom_fields/add_field_flyout.tsx | 100 +++++++++++++ .../components/custom_fields/builder.tsx | 16 +++ .../custom_fields_list/index.test.tsx | 68 +++++++++ .../custom_fields_list/index.tsx | 88 ++++++++++++ .../components/custom_fields/form.test.tsx | 59 ++++++++ .../public/components/custom_fields/form.tsx | 69 +++++++++ .../custom_fields/form_fields.test.tsx | 64 +++++++++ .../components/custom_fields/form_fields.tsx | 91 ++++++++++++ .../components/custom_fields/index.test.tsx | 80 +++++++++++ .../public/components/custom_fields/index.tsx | 84 +++++++++++ .../components/custom_fields/schema.tsx | 47 ++++++ .../text/configure_text_field.test.tsx | 59 ++++++++ .../text/configure_text_field.tsx | 36 +++++ .../toggle/configure_toggle_field.test.tsx | 59 ++++++++ .../toggle/configure_toggle_field.tsx | 37 +++++ .../components/custom_fields/translations.ts | 67 +++++++++ .../public/components/custom_fields/types.ts | 21 +++ .../cases/public/containers/configure/api.ts | 1 + .../public/containers/configure/types.ts | 1 + .../configure/use_configure.test.tsx | 7 + .../containers/configure/use_configure.tsx | 45 +++++- 26 files changed, 1402 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/builder.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/form.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/form.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/index.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/schema.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.tsx create mode 100644 x-pack/plugins/cases/public/components/custom_fields/translations.ts create mode 100644 x-pack/plugins/cases/public/components/custom_fields/types.ts diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 9dea7a3413f95..668eabb796e15 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -197,7 +197,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureC }); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', { - defaultMessage: 'Edit external connection', + defaultMessage: 'Settings', }); export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', { diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index 28267d407cb3f..c69aa95fed0f0 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -34,10 +34,12 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = { fields: null, }, closureType: 'close-by-user', + customFields: [], }, firstLoad: false, loading: false, mappings: [], + customFields: [], persistCaseConfigure: jest.fn(), persistLoading: false, refetchCaseConfigure: jest.fn(), @@ -45,6 +47,7 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = { setConnector: jest.fn(), setCurrentConfiguration: jest.fn(), setMappings: jest.fn(), + setCustomFields: jest.fn(), version: '', id: '', }; 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 5272eaebad512..6629dd032061b 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 @@ -8,10 +8,12 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; -import { noUpdateCasesPermissions, TestProviders } from '../../common/mock'; +import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; +import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -25,7 +27,8 @@ import { useConnectorsResponse, useActionTypesResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../common/types/domain'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; @@ -422,6 +425,7 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-user', + customFields: [], }); }); @@ -511,6 +515,7 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-pushing', + customFields: [], }); }); }); @@ -597,4 +602,129 @@ describe('ConfigureCases', () => { ).toBeFalsy(); }); }); + + describe('custom fields', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders custom field group when no custom fields available', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + }); + + it('renders custom field when available', () => { + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + ]; + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + customFields: customFieldsMock, + currentConfiguration: { + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-user', + customFields: customFieldsMock, + }, + })); + appMockRender.render(); + + const draggable = screen.getByTestId('draggable'); + + expect( + within(draggable).getByTestId( + `custom-field-${customFieldsMock[0].label}-${customFieldsMock[0].type}` + ) + ).toBeInTheDocument(); + }); + + it('renders multiple custom field when available', () => { + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'Summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + { + key: 'random_custom_key_2', + label: 'Maintenance', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + customFields: customFieldsMock, + currentConfiguration: { + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + closureType: 'close-by-user', + customFields: customFieldsMock, + }, + })); + appMockRender.render(); + + const droppable = screen.getByTestId('droppable'); + + for (const field of customFieldsMock) { + expect( + within(droppable).getByTestId(`custom-field-${field.label}-${field.type}`) + ).toBeInTheDocument(); + } + }); + + it('opens fly out for when click on add field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('add-custom-field-flyout')).toBeInTheDocument(); + }); + + it('closes fly out for when click on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('add-custom-field-flyout')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-cancel')); + + expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('add-custom-field-flyout')).not.toBeInTheDocument(); + }); + + it('closes fly out for when click on save field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('add-custom-field-flyout')).toBeInTheDocument(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-save')); + + expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('add-custom-field-flyout')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index fcbc455345b21..d9be2c84d7c92 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiCallOut, EuiLink, EuiPageBody } from '@elastic/eui'; +import { EuiCallOut, EuiFlexItem, EuiLink, EuiPageBody } from '@elastic/eui'; import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; @@ -29,7 +29,10 @@ import { HeaderPage } from '../header_page'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { CasesDeepLinkId } from '../../common/navigation'; +import { CustomFields } from '../custom_fields'; +import { AddFieldFlyout } from '../custom_fields/add_field_flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; const FormWrapper = styled.div` ${({ theme }) => css` @@ -60,9 +63,11 @@ export const ConfigureCases: React.FC = React.memo(() => { const [editedConnectorItem, setEditedConnectorItem] = useState( null ); + const [addFieldFlyoutVisible, setAddFieldFlyoutVisibility] = useState(false); const { connector, + customFields, closureType, loading: loadingCaseConfigure, mappings, @@ -71,6 +76,7 @@ export const ConfigureCases: React.FC = React.memo(() => { refetchCaseConfigure, setConnector, setClosureType, + setCustomFields, } = useCaseConfigure(); const { @@ -101,11 +107,12 @@ export const ConfigureCases: React.FC = React.memo(() => { await persistCaseConfigure({ connector: caseConnector, closureType, + customFields, }); onConnectorUpdated(createdConnector); setConnector(caseConnector); }, - [onConnectorUpdated, closureType, setConnector, persistCaseConfigure] + [onConnectorUpdated, closureType, setConnector, customFields, persistCaseConfigure] ); const isLoadingAny = @@ -137,9 +144,10 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector: caseConnector, closureType, + customFields, }); }, - [connectors, closureType, persistCaseConfigure, setConnector] + [connectors, closureType, customFields, persistCaseConfigure, setConnector] ); const onChangeClosureType = useCallback( @@ -148,9 +156,10 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector, closureType: type, + customFields, }); }, - [connector, persistCaseConfigure, setClosureType] + [connector, customFields, persistCaseConfigure, setClosureType] ); useEffect(() => { @@ -202,6 +211,45 @@ export const ConfigureCases: React.FC = React.memo(() => { [connector.id, editedConnectorItem, editFlyoutVisible] ); + const onAddCustomFields = useCallback(() => { + setAddFieldFlyoutVisibility(true); + }, [setAddFieldFlyoutVisibility]); + + const onCloseAddFieldFlyout = useCallback(() => { + setAddFieldFlyoutVisibility(false); + }, [setAddFieldFlyoutVisibility]); + + const onCustomFieldCreated = useCallback( + (customFieldData: CustomFieldsConfiguration) => { + const data = customFields.length ? [...customFields, ...customFieldData] : customFieldData; + setCustomFields(data); + persistCaseConfigure({ + connector, + closureType, + customFields: [...customFields, ...customFieldData], + }); + + setAddFieldFlyoutVisibility(false); + }, + [ + setAddFieldFlyoutVisibility, + customFields, + setCustomFields, + persistCaseConfigure, + connector, + closureType, + ] + ); + + const CustomFieldAddFlyout = addFieldFlyoutVisible ? ( + + ) : null; + return ( <> { updateConnectorDisabled={updateConnectorDisabled || !permissions.update} /> + + + + + {ConnectorAddFlyout} {ConnectorEditFlyout} + {CustomFieldAddFlyout} diff --git a/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.test.tsx new file mode 100644 index 0000000000000..a8056350f76bd --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { AddFieldFlyout } from './add_field_flyout'; + +describe('AddFieldFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('add-custom-field-flyout-header')).toBeInTheDocument(); + expect(screen.getByTestId('add-custom-field-flyout-cancel')).toBeInTheDocument(); + expect(screen.getByTestId('add-custom-field-flyout-save')).toBeInTheDocument(); + }); + + it('calls onSaveField on save field', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('text-custom-field-options')); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith([ + { + key: expect.anything(), + label: 'Summary', + required: true, + type: 'text', + }, + ]); + }); + }); + + it('calls onSaveField with serialized data', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith([ + { + key: expect.anything(), + label: 'Summary', + required: false, + type: 'text', + }, + ]); + }); + }); + + it('does not call onSaveField when error', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-save')); + + expect(props.onSaveField).not.toBeCalled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field-flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.tsx new file mode 100644 index 0000000000000..3c76416eb8e97 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/add_field_flyout.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { CustomFieldFormState } from './form'; +import { CustomFieldsForm } from './form'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; + +import * as i18n from './translations'; + +export interface AddFieldFlyoutProps { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: CustomFieldsConfiguration) => void; +} + +const AddFieldFlyoutComponent: React.FC = ({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, +}) => { + const dataTestSubj = 'add-custom-field-flyout'; + + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: {} }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + onSaveField(data as CustomFieldsConfiguration); + } + }, [onSaveField, submit]); + + return ( + + + +

{i18n.ADD_CUSTOM_FIELD}

+
+
+ + + + + + + + {i18n.CANCEL} + + + + + + + {i18n.SAVE_FIELD} + + + + + +
+ ); +}; + +AddFieldFlyoutComponent.displayName = 'AddFieldFlyout'; + +export const AddFieldFlyout = React.memo(AddFieldFlyoutComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx new file mode 100644 index 0000000000000..caa8bea5899d0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CustomFieldBuilderMap } from './types'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { configureTextCustomFieldBuilder } from './text/configure_text_field'; +import { configureToggleCustomFieldBuilder } from './toggle/configure_toggle_field'; + +export const builderMap: CustomFieldBuilderMap = { + [CustomFieldTypes.TEXT]: configureTextCustomFieldBuilder, + [CustomFieldTypes.TOGGLE]: configureToggleCustomFieldBuilder, +}; 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 new file mode 100644 index 0000000000000..b3aef03879ce3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import type { CustomFieldsConfiguration } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import { CustomFieldsList } from '.'; + +describe('CustomFieldsList', () => { + let appMockRender: AppMockRenderer; + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'Summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + { + key: 'random_custom_key_2', + label: 'Maintenance', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const props = { + customFields: customFieldsMock, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('droppable')).toBeInTheDocument(); + }); + + it('shows CustomFieldsList correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('droppable')).toBeInTheDocument(); + expect(screen.getAllByTestId('draggable').length).toEqual(2); + }); + + it('shows single CustomFieldsList correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('droppable')).toBeInTheDocument(); + expect(screen.getAllByTestId('draggable').length).toEqual(1); + }); + + it('does not show droppable field when no custom fields', () => { + appMockRender.render(); + + expect(screen.queryByTestId('droppable')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx new file mode 100644 index 0000000000000..b9c8c65fac3d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx @@ -0,0 +1,88 @@ +/* + * 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 { + EuiDragDropContext, + EuiDroppable, + EuiDraggable, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiIcon, +} from '@elastic/eui'; + +import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; +import { builderMap } from '../builder'; + +export interface Props { + customFields: CustomFieldsConfiguration; +} + +const CustomFieldsListComponent: React.FC = (props) => { + const { customFields } = props; + + const renderTypeLabel = (type?: CustomFieldTypes) => { + const createdBuilder = type && builderMap[type]; + + return createdBuilder && createdBuilder().label; + }; + + return ( + <> + + + + {customFields.length ? ( + {}}> + + {customFields.map(({ key, type, label }, idx) => ( + + {() => ( + + + + + + + + +

{label}

+
+ {renderTypeLabel(type)} +
+
+
+
+ )} +
+ ))} +
+
+ ) : null} +
+
+ + ); +}; + +CustomFieldsListComponent.displayName = 'CustomFieldsList'; + +export const CustomFieldsList = React.memo(CustomFieldsListComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx new file mode 100644 index 0000000000000..695e95d0f85d2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.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 React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { CustomFieldsForm } from './form'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import * as i18n from './translations'; + +describe('CustomFieldsForm ', () => { + let appMockRender: AppMockRenderer; + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('renders text as default custom field type', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(screen.getByText('Text')).toBeInTheDocument(); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('renders custom field type options', async () => { + appMockRender.render(); + + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Toggle')).toBeInTheDocument(); + }); + + it('renders toggle custom field type', async () => { + appMockRender.render(); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + expect(screen.getByTestId('toggle-custom-field-options')).toBeInTheDocument(); + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx new file mode 100644 index 0000000000000..770c4d985c192 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { FormProps } from './schema'; +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +export interface CustomFieldFormState { + isValid: boolean | undefined; + submit: FormHook['submit']; +} + +interface Props { + onChange: (state: CustomFieldFormState) => void; +} + +const FormComponent: React.FC = ({ onChange }) => { + const formSerializer = ({ key, label, type, options }: FormProps) => { + const customFieldKey = key ? key : uuidv4(); + + const serializedData = [ + { + key: customFieldKey, + label, + type, + required: options?.required ? options.required : false, + }, + ] as CustomFieldsConfiguration; + + return serializedData; + }; + + const { form } = useForm({ + defaultValue: { type: CustomFieldTypes.TEXT, options: { required: false } }, + options: { stripEmptyFields: false }, + schema, + // @ts-ignore + serializer: formSerializer, + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( +
+ + + ); +}; + +FormComponent.displayName = 'CustomFieldsForm'; + +export const CustomFieldsForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx new file mode 100644 index 0000000000000..1803d3a21ae40 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { FormFields } from './form_fields'; + +describe('FormFields ', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('submit data correctly', async () => { + appMockRender.render( + + + + ); + + userEvent.type(screen.getByTestId('custom-field-label-input'), 'hello'); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + label: 'hello', + type: 'toggle', + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx new file mode 100644 index 0000000000000..3a4489d60c487 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { EuiSelectOption } from '@elastic/eui'; +import type { CustomFieldBuildType } from './types'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { builderMap } from './builder'; + +interface FormFieldsProps { + isSubmitting?: boolean; +} + +const fieldTypeSelectOptions = (): EuiSelectOption[] => { + const options = []; + + for (const [id, builder] of Object.entries(builderMap)) { + const createdBuilder = builder(); + options.push({ value: id, text: createdBuilder.label }); + } + + return options; +}; + +const FormFieldsComponent: React.FC = ({ isSubmitting }) => { + const [selectedType, setSelectedType] = useState(CustomFieldTypes.TEXT); + + const handleTypeChange = useCallback( + (e) => { + setSelectedType(e.target.value); + }, + [setSelectedType] + ); + + const builtCustomField: CustomFieldBuildType | null = useMemo(() => { + const builder = builderMap[selectedType]; + + if (builder == null) { + return null; + } + + const customFieldBuilder = builder(); + + return customFieldBuilder.build(); + }, [selectedType]); + + const ConfigurePage = builtCustomField?.ConfigurePage; + const options = fieldTypeSelectOptions(); + + return ( + <> + + + {ConfigurePage ? : null} + + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); 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 new file mode 100644 index 0000000000000..7b15b33a33728 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/dom'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { CustomFields } from '.'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'Summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + { + key: 'random_custom_key_2', + label: 'Maintenance', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const props = { + disabled: false, + isLoading: false, + handleAddCustomField: jest.fn(), + customFields: [], + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + expect(screen.getByTestId('droppable')).toBeInTheDocument(); + }); + + it('renders loading state correctly', () => { + appMockRender.render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(props.handleAddCustomField).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.tsx new file mode 100644 index 0000000000000..86ce6be8a07b0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiButtonEmpty, EuiDescribedFormGroup, EuiSpacer } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import * as i18n from './translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { CustomFieldsList } from './custom_fields_list'; + +export interface Props { + customFields: CustomFieldsConfiguration; + disabled: boolean; + isLoading: boolean; + handleAddCustomField: () => void; +} +const CustomFieldsComponent: React.FC = ({ + disabled, + isLoading, + handleAddCustomField, + customFields, +}) => { + const { permissions } = useCasesContext(); + const canAddCustomFields = permissions.create && permissions.update; + + const renderBody = customFields?.length ? ( + + ) : ( + <> + + {i18n.NO_CUSTOM_FIELDS} + + ); + + return canAddCustomFields ? ( + {i18n.TITLE}} + description={ + <> +

{i18n.DESCRIPTION}

+ + } + data-test-subj="custom-fields-form-group" + > + + {i18n.ADD_CUSTOM_FIELD} + + } + /> +
+ ) : null; +}; +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/schema.tsx b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx new file mode 100644 index 0000000000000..d3fdde921d9d6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from './translations'; +import type { CustomFieldTypes } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; + +const { emptyField, maxLengthField } = fieldValidators; + +export interface FormProps { + key?: string; + label: string; + type: CustomFieldTypes; + options: { + required?: boolean | string; + }; +} + +export const schema = { + label: { + label: i18n.FIELD_LABEL, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + { + validator: maxLengthField({ + length: MAX_CUSTOM_FIELD_LABEL_LENGTH, + message: i18n.MAX_LENGTH_ERROR('fieldLabel', MAX_CUSTOM_FIELD_LABEL_LENGTH), + }), + }, + ], + }, + type: { + label: i18n.FIELD_TYPE, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.tsx new file mode 100644 index 0000000000000..9b31d4e063cf0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.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 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 { configureTextCustomFieldBuilder } from './configure_text_field'; +import * as i18n from '../translations'; + +describe('configureTextCustomFieldBuilder ', () => { + const onSubmit = jest.fn(); + const builder = configureTextCustomFieldBuilder(); + + const BuiltCustomField = builder.build().ConfigurePage; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.tsx new file mode 100644 index 0000000000000..4c5072654f3de --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CustomFieldBuilder } from '../types'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; + +export const configureTextCustomFieldBuilder: CustomFieldBuilder = () => ({ + id: CustomFieldTypes.TEXT, + label: i18n.TEXT_LABEL, + build: () => ({ + // eslint-disable-next-line react/display-name + ConfigurePage: () => ( + <> + + + ), + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.tsx new file mode 100644 index 0000000000000..32c9de1661150 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.test.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 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 { configureToggleCustomFieldBuilder } from './configure_toggle_field'; +import * as i18n from '../translations'; + +describe('configureToggleCustomFieldBuilder ', () => { + const onSubmit = jest.fn(); + const builder = configureToggleCustomFieldBuilder(); + + const BuiltCustomField = builder.build().ConfigurePage; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.tsx new file mode 100644 index 0000000000000..aa240d10f16d3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; + +import type { CustomFieldBuilder } from '../types'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; + +export const configureToggleCustomFieldBuilder: CustomFieldBuilder = () => ({ + id: CustomFieldTypes.TOGGLE, + label: i18n.TOGGLE_LABEL, + build: () => ({ + // eslint-disable-next-line react/display-name + ConfigurePage: () => ( + <> + + + ), + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts new file mode 100644 index 0000000000000..8e00ebd650622 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const TITLE = i18n.translate('xpack.cases.customFields.title', { + defaultMessage: 'Custom Fields', +}); + +export const DESCRIPTION = i18n.translate('xpack.cases.customFields.description', { + defaultMessage: 'Add more optional and required fields for customized case collaboration.', +}); + +export const NO_CUSTOM_FIELDS = i18n.translate('xpack.cases.customFields.noCustomFields', { + defaultMessage: 'You do not have any fields yet', +}); + +export const ADD_CUSTOM_FIELD = i18n.translate('xpack.cases.customFields.addCustomField', { + defaultMessage: 'Add field', +}); + +export const SAVE_FIELD = i18n.translate('xpack.cases.customFields.saveField', { + defaultMessage: 'Save field', +}); + +export const FIELD_LABEL = i18n.translate('xpack.cases.customFields.fieldLabel', { + defaultMessage: 'Field label', +}); + +export const FIELD_LABEL_HELP_TEXT = i18n.translate('xpack.cases.customFields.fieldLabelHelpText', { + defaultMessage: '50 characters max', +}); + +export const TEXT_LABEL = i18n.translate('xpack.cases.customFields.textLabel', { + defaultMessage: 'Text', +}); + +export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel', { + defaultMessage: 'Toggle', +}); + +export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', { + defaultMessage: 'Field type', +}); + +export const FIELD_OPTIONS = i18n.translate('xpack.cases.customFields.fieldOptions', { + defaultMessage: 'Options', +}); + +export const FIELD_OPTION_REQUIRED = i18n.translate( + 'xpack.cases.customFields.fieldOptions.Required', + { + defaultMessage: 'Make this field required', + } +); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.customFields.requiredField', { + values: { fieldName }, + defaultMessage: '{fieldName} is required.', + }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts new file mode 100644 index 0000000000000..0ee1b90cc125c --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; +import type { CustomFieldTypes } from '../../../common/types/domain'; + +export interface CustomFieldBuildType { + ConfigurePage: React.FC; +} + +export type CustomFieldBuilder = () => { + id: string; + label: string; + build: () => CustomFieldBuildType; +}; + +export type CustomFieldBuilderMap = Record; diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index f1d86cb090234..8dd3e6c2b76e1 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -79,6 +79,7 @@ export const patchCaseConfigure = async ( signal, } ); + return convertToCamelCase(decodeCaseConfigureResponse(response)); }; diff --git a/x-pack/plugins/cases/public/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts index dde8e5b217c1d..a980ebadc34ea 100644 --- a/x-pack/plugins/cases/public/containers/configure/types.ts +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -26,6 +26,7 @@ export type { ConnectorMappingSource, ConnectorMappingTarget, ClosureType, + CustomFieldsConfiguration, }; export interface CaseConnectorMapping { diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx index 8919c829585cf..9a1f44a1dadda 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx @@ -37,6 +37,7 @@ const configuration: ConnectorConfiguration = { fields: null, }, closureType: 'close-by-pushing', + customFields: [], }; describe('useConfigure', () => { @@ -59,6 +60,7 @@ describe('useConfigure', () => { setConnector: result.current.setConnector, setClosureType: result.current.setClosureType, setMappings: result.current.setMappings, + setCustomFields: result.current.setCustomFields, }) ); }); @@ -79,6 +81,7 @@ describe('useConfigure', () => { currentConfiguration: { closureType: caseConfigurationCamelCaseResponseMock.closureType, connector: caseConfigurationCamelCaseResponseMock.connector, + customFields: caseConfigurationCamelCaseResponseMock.customFields, }, mappings: [], firstLoad: true, @@ -89,6 +92,7 @@ describe('useConfigure', () => { setConnector: result.current.setConnector, setCurrentConfiguration: result.current.setCurrentConfiguration, setMappings: result.current.setMappings, + setCustomFields: result.current.setCustomFields, version: caseConfigurationCamelCaseResponseMock.version, id: caseConfigurationCamelCaseResponseMock.id, }); @@ -301,6 +305,7 @@ describe('useConfigure', () => { setConnector: result.current.setConnector, setCurrentConfiguration: result.current.setCurrentConfiguration, setMappings: result.current.setMappings, + setCustomFields: result.current.setCustomFields, }); }); }); @@ -338,6 +343,7 @@ describe('useConfigure', () => { currentConfiguration: { closureType: caseConfigurationCamelCaseResponseMock.closureType, connector: caseConfigurationCamelCaseResponseMock.connector, + customFields: caseConfigurationCamelCaseResponseMock.customFields, }, firstLoad: true, loading: false, @@ -348,6 +354,7 @@ describe('useConfigure', () => { setConnector: result.current.setConnector, setCurrentConfiguration: result.current.setCurrentConfiguration, setMappings: result.current.setMappings, + setCustomFields: result.current.setCustomFields, }); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx index 12168b1b9c5c5..4e080698df900 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx @@ -10,16 +10,24 @@ import { ConnectorTypes } from '../../../common/types/domain'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; import * as i18n from './translations'; -import type { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; +import type { + ClosureType, + CaseConfigure, + CaseConnector, + CaseConnectorMapping, + CustomFieldsConfiguration, +} from './types'; import { useToasts } from '../../common/lib/kibana'; import { useCasesContext } from '../../components/cases_context/use_cases_context'; export type ConnectorConfiguration = { connector: CaseConnector } & { closureType: CaseConfigure['closureType']; + customFields: CustomFieldsConfiguration; }; export interface State extends ConnectorConfiguration { currentConfiguration: ConnectorConfiguration; + customFields: CustomFieldsConfiguration; firstLoad: boolean; loading: boolean; mappings: CaseConnectorMapping[]; @@ -63,6 +71,10 @@ export type Action = | { type: 'setMappings'; mappings: CaseConnectorMapping[]; + } + | { + type: 'setCustomFields'; + customFields: CustomFieldsConfiguration; }; export const configureCasesReducer = (state: State, action: Action) => { @@ -116,18 +128,29 @@ export const configureCasesReducer = (state: State, action: Action) => { mappings: action.mappings, }; } + case 'setCustomFields': { + return { + ...state, + customFields: action.customFields, + }; + } default: return state; } }; export interface ReturnUseCaseConfigure extends State { - persistCaseConfigure: ({ connector, closureType }: ConnectorConfiguration) => unknown; + persistCaseConfigure: ({ + connector, + closureType, + customFields, + }: ConnectorConfiguration) => unknown; refetchCaseConfigure: () => void; setClosureType: (closureType: ClosureType) => void; setConnector: (connector: CaseConnector) => void; setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; setMappings: (newMapping: CaseConnectorMapping[]) => void; + setCustomFields: (customFields: CustomFieldsConfiguration) => void; } export const initialState: State = { @@ -138,6 +161,7 @@ export const initialState: State = { name: 'none', type: ConnectorTypes.none, }, + customFields: [], currentConfiguration: { closureType: 'close-by-user', connector: { @@ -146,6 +170,7 @@ export const initialState: State = { name: 'none', type: ConnectorTypes.none, }, + customFields: [], }, firstLoad: false, loading: true, @@ -180,6 +205,13 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); }, []); + const setCustomFields = useCallback((customFields: CustomFieldsConfiguration) => { + dispatch({ + customFields, + type: 'setCustomFields', + }); + }, []); + const setMappings = useCallback((mappings: CaseConnectorMapping[]) => { dispatch({ mappings, @@ -249,6 +281,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setVersion(res.version); setID(res.id); setMappings(res.mappings); + setCustomFields(res.customFields); if (!state.firstLoad) { setFirstLoad(true); @@ -258,6 +291,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { connector: { ...res.connector, }, + customFields: [...res.customFields], }); } } @@ -284,7 +318,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, [state.firstLoad]); const persistCaseConfigure = useCallback( - async ({ connector, closureType }: ConnectorConfiguration) => { + async ({ connector, closureType, customFields }: ConnectorConfiguration) => { try { isCancelledPersistRef.current = false; abortCtrlPersistRef.current.abort(); @@ -294,6 +328,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { const connectorObj = { connector, closure_type: closureType, + customFields, }; const res = @@ -316,6 +351,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { if (setClosureType) { setClosureType(res.closureType); } + setCustomFields(res.customFields); setVersion(res.version); setID(res.id); setMappings(res.mappings); @@ -325,6 +361,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { connector: { ...res.connector, }, + customFields: [...res.customFields], }); } if (res.error != null) { @@ -363,6 +400,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setMappings, setCurrentConfiguration, toasts, + setCustomFields, ] ); @@ -385,5 +423,6 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setConnector, setClosureType, setMappings, + setCustomFields, }; };