From 37ac85fb5ff229a59bda30af4c15ae9b7c821398 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 14 May 2024 18:10:17 +0200 Subject: [PATCH 01/28] initial commit --- .../components/case_form_fields/index.tsx | 76 +++++++ .../category/category_form_field.tsx | 4 +- .../configure_cases/__mock__/index.tsx | 1 + .../components/configure_cases/flyout.tsx | 121 +++++++++++ .../components/configure_cases/index.test.tsx | 20 +- .../components/configure_cases/index.tsx | 181 +++++++++++----- .../configure_cases/translations.ts | 11 + .../public/components/create/assignees.tsx | 5 +- .../public/components/create/category.tsx | 4 +- .../components/create/custom_fields.tsx | 6 +- .../public/components/create/description.tsx | 5 +- .../public/components/create/severity.tsx | 5 +- .../components/create/sync_alerts_toggle.tsx | 5 +- .../cases/public/components/create/tags.tsx | 16 +- .../cases/public/components/create/title.tsx | 5 +- .../components/custom_fields/text/create.tsx | 10 +- .../custom_fields/toggle/create.tsx | 5 +- .../public/components/custom_fields/types.ts | 2 + .../components/custom_fields/utils.test.ts | 193 +---------------- .../public/components/custom_fields/utils.ts | 47 +++-- .../public/components/templates/form.tsx | 60 ++++++ .../components/templates/form_fields.tsx | 81 +++++++ .../public/components/templates/index.tsx | 111 ++++++++++ .../public/components/templates/schema.tsx | 135 ++++++++++++ .../components/templates/templates_list.tsx | 86 ++++++++ .../components/templates/translations.ts | 56 +++++ .../public/components/templates/types.ts | 15 ++ .../public/components/templates/utils.ts | 53 +++++ .../cases/public/components/utils.test.ts | 199 +++++++++++++++++- .../plugins/cases/public/components/utils.ts | 16 ++ .../use_persist_configuration.test.tsx | 4 + .../configure/use_persist_configuration.tsx | 4 +- 32 files changed, 1243 insertions(+), 299 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/index.tsx create mode 100644 x-pack/plugins/cases/public/components/configure_cases/flyout.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/form.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/form_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/index.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/schema.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/templates_list.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/translations.ts create mode 100644 x-pack/plugins/cases/public/components/templates/types.ts create mode 100644 x-pack/plugins/cases/public/components/templates/utils.ts diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx new file mode 100644 index 00000000000000..7b95f37752962f --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { EuiThemeComputed } from '@elastic/eui'; +import { logicalCSS, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { Title } from '../create/title'; +import { Tags } from '../create/tags'; +import { Category } from '../create/category'; +import { Severity } from '../create/severity'; +import { Description } from '../create/description'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { Assignees } from '../create/assignees'; +import { CustomFields } from '../create/custom_fields'; +import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; + +const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => + big + ? css` + ${logicalCSS('margin-top', euiTheme.size.xl)}; + ` + : css` + ${logicalCSS('margin-top', euiTheme.size.base)}; + `; + +const CaseFormFieldsComponent: React.FC = () => { + const { isSubmitting } = useFormContext(); + const { caseAssignmentAuthorized, isSyncAlertsEnabled } = useCasesFeatures(); + const { euiTheme } = useEuiTheme(); + + return ( + <> + + {caseAssignmentAuthorized ? ( + <div css={containerCss(euiTheme)}> + <Assignees isLoading={isSubmitting} path="caseFields.assignees" /> + </div> + ) : null} + <div css={containerCss(euiTheme)}> + <Tags isLoading={isSubmitting} path="caseFields.tags" /> + </div> + <div css={containerCss(euiTheme)}> + <Category isLoading={isSubmitting} path="caseFields.category" /> + </div> + <div css={containerCss(euiTheme)}> + <Severity isLoading={isSubmitting} path="caseFields.severity" /> + </div> + <div css={containerCss(euiTheme, true)}> + <Description isLoading={isSubmitting} draftStorageKey={''} path="caseFields.description" /> + </div> + {isSyncAlertsEnabled ? ( + <div> + <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.settings" /> + </div> + ) : null} + <div css={containerCss(euiTheme)}> + <CustomFields + isLoading={isSubmitting} + path="caseFields.customFields" + setAsOptional={true} + /> + </div> + <div /> + </> + ); +}; + +CaseFormFieldsComponent.displayName = 'CaseFormFields'; + +export const CaseFormFields = memo(CaseFormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index 060e0928b89860..2f44948bbf2241 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -23,6 +23,7 @@ interface Props { isLoading: boolean; availableCategories: string[]; formRowProps?: Partial<EuiFormRowProps>; + path?: string; } type CategoryField = CaseUI['category'] | undefined; @@ -63,9 +64,10 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ isLoading, availableCategories, formRowProps, + path, }) => { return ( - <UseField<CategoryField> path={'category'} config={getCategoryConfig()}> + <UseField<CategoryField> path={path ?? 'category'} config={getCategoryConfig()}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); 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 e0161e437e70d9..a55e67e5864587 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 @@ -27,6 +27,7 @@ const mockConfigurationData = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx new file mode 100644 index 00000000000000..3e4d453861b5a5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { CustomFieldFormState } from '../custom_fields/form'; +import type { TemplateFormState } from '../templates/form'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; +import type { CustomFieldConfiguration, TemplateConfiguration } from '../../../common/types/domain'; + +import * as i18n from './translations'; + +export interface FlyoutProps { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: CustomFieldConfiguration | TemplateConfiguration | null) => void; + data: CustomFieldConfiguration | TemplateConfiguration | null; + type: 'customField' | 'template'; +} + +const FlyoutComponent: React.FC<FlyoutProps> = ({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, + data: initialValue, + type, +}) => { + const dataTestSubj = `${type}Flyout`; + + const [formState, setFormState] = useState<CustomFieldFormState | TemplateFormState>({ + isValid: undefined, + submit: async () => ({ + isValid: false, + data: null, + }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + onSaveField(data as CustomFieldConfiguration | TemplateConfiguration | null); + } + }, [onSaveField, submit]); + + return ( + <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> + <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}-header`}> + <EuiTitle size="s"> + <h3 id="flyoutTitle"> + {type === 'customField' ? i18n.ADD_CUSTOM_FIELD : i18n.CRATE_TEMPLATE} + </h3> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + {type === 'customField' ? ( + <CustomFieldsForm + onChange={setFormState} + initialValue={initialValue as CustomFieldConfiguration} + /> + ) : null} + {type === 'template' ? ( + <TemplateForm + onChange={setFormState} + initialValue={initialValue as TemplateConfiguration} + /> + ) : null} + </EuiFlyoutBody> + <EuiFlyoutFooter data-test-subj={`${dataTestSubj}-footer`}> + <EuiFlexGroup justifyContent="flexStart"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={onCloseFlyout} + data-test-subj={`${dataTestSubj}-cancel`} + disabled={disabled} + isLoading={isLoading} + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButton + fill + onClick={handleSaveField} + data-test-subj={`${dataTestSubj}-save`} + disabled={disabled} + isLoading={isLoading} + > + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </EuiFlyoutFooter> + </EuiFlyout> + ); +}; + +FlyoutComponent.displayName = 'CommonFlyout'; + +export const CommonFlyout = React.memo(FlyoutComponent); 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 ba3e7850533c93..2a62ee87badb31 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 @@ -729,11 +729,11 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) ); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('customFieldFlyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -756,6 +756,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -767,7 +768,7 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); }); it('closes fly out for when click on cancel', async () => { @@ -775,12 +776,12 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + userEvent.click(screen.getByTestId('customFieldFlyout-cancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('customFieldFlyout')).not.toBeInTheDocument(); }); it('closes fly out for when click on save field', async () => { @@ -788,11 +789,11 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('custom-field-flyout-save')); + userEvent.click(screen.getByTestId('customFieldFlyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -812,13 +813,14 @@ describe('ConfigureCases', () => { required: false, }, ], + templates: [], id: '', version: '', }); }); expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('customFieldFlyout')).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 d33726d7ccdfe6..f7bb72c0e8ef08 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -22,7 +22,12 @@ import { import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import type { + CustomFieldConfiguration, + CustomFieldsConfiguration, + TemplateConfiguration, + TemplatesConfiguration, +} from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; @@ -32,17 +37,18 @@ import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils'; import * as i18n from './translations'; -import { getConnectorById } from '../utils'; +import { getConnectorById, addOrReplaceField } from '../utils'; import { HeaderPage } from '../header_page'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { CasesDeepLinkId } from '../../common/navigation'; import { CustomFields } from '../custom_fields'; -import { CustomFieldFlyout } from '../custom_fields/flyout'; +import { CommonFlyout } from './flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; -import { addOrReplaceCustomField } from '../custom_fields/utils'; +import { transformCustomFieldsData } from '../custom_fields/utils'; import { useLicense } from '../../common/use_license'; +import { Templates } from '../templates'; const sectionWrapperCss = css` box-sizing: content-box; @@ -58,6 +64,11 @@ const getFormWrapperCss = (euiTheme: EuiThemeComputed<{}>) => css` } `; +interface Flyout { + type: 'addConnector' | 'editConnector' | 'customField' | 'template'; + visible: boolean; +} + export const ConfigureCases: React.FC = React.memo(() => { const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; @@ -66,14 +77,19 @@ export const ConfigureCases: React.FC = React.memo(() => { const hasMinimumLicensePermissions = license.isAtLeastGold(); const [connectorIsValid, setConnectorIsValid] = useState(true); - const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); - const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false); + const [flyOutVisibility, setFlyOutVisibility] = useState<Flyout | null>(null); const [editedConnectorItem, setEditedConnectorItem] = useState<ActionConnectorTableItem | null>( null ); - const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState<boolean>(false); const [customFieldToEdit, setCustomFieldToEdit] = useState<CustomFieldConfiguration | null>(null); + const [templateToEdit, setTemplateToEdit] = useState<TemplateConfiguration | null>(null); const { euiTheme } = useEuiTheme(); + const flyoutType = + flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible + ? 'customField' + : flyOutVisibility?.type === 'template' && flyOutVisibility?.visible + ? 'template' + : null; const { data: { @@ -83,6 +99,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector, mappings, customFields, + templates, }, isLoading: loadingCaseConfigure, refetch: refetchCaseConfigure, @@ -148,20 +165,23 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { - setEditFlyoutVisibility(true); + setFlyOutVisibility({ type: 'editConnector', visible: true }); }, []); const onCloseAddFlyout = useCallback( - () => setAddFlyoutVisibility(false), - [setAddFlyoutVisibility] + () => setFlyOutVisibility({ type: 'addConnector', visible: false }), + [setFlyOutVisibility] ); - const onCloseEditFlyout = useCallback(() => setEditFlyoutVisibility(false), []); + const onCloseEditFlyout = useCallback( + () => setFlyOutVisibility({ type: 'editConnector', visible: false }), + [] + ); const onChangeConnector = useCallback( (id: string) => { if (id === 'add-connector') { - setAddFlyoutVisibility(true); + setFlyOutVisibility({ type: 'addConnector', visible: true }); return; } @@ -225,7 +245,7 @@ export const ConfigureCases: React.FC = React.memo(() => { const ConnectorAddFlyout = useMemo( () => - addFlyoutVisible + flyOutVisibility?.type === 'addConnector' && flyOutVisibility?.visible ? triggersActionsUi.getAddConnectorFlyout({ onClose: onCloseAddFlyout, featureId: CasesConnectorFeatureId, @@ -233,12 +253,12 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [addFlyoutVisible] + [flyOutVisibility] ); const ConnectorEditFlyout = useMemo( () => - editedConnectorItem && editFlyoutVisible + editedConnectorItem && flyOutVisibility?.type === 'editConnector' && flyOutVisibility?.visible ? triggersActionsUi.getEditConnectorFlyout({ connector: editedConnectorItem, onClose: onCloseEditFlyout, @@ -246,12 +266,13 @@ export const ConfigureCases: React.FC = React.memo(() => { }) : null, // eslint-disable-next-line react-hooks/exhaustive-deps - [connector.id, editedConnectorItem, editFlyoutVisible] + [connector.id, editedConnectorItem, flyOutVisibility] ); - const onAddCustomFields = useCallback(() => { - setCustomFieldFlyoutVisibility(true); - }, [setCustomFieldFlyoutVisibility]); + // const onAddCustomFields = useCallback(() => { + // // setCustomFieldFlyoutVisibility(true); + // setFlyOutVisibility({ type: 'customField', visible: true }); + // }, [setFlyOutVisibility]); const onDeleteCustomField = useCallback( (key: string) => { @@ -282,29 +303,68 @@ export const ConfigureCases: React.FC = React.memo(() => { if (selectedCustomField) { setCustomFieldToEdit(selectedCustomField); } - setCustomFieldFlyoutVisibility(true); + setFlyOutVisibility({ type: 'customField', visible: true }); }, - [setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields] + [setFlyOutVisibility, setCustomFieldToEdit, customFields] ); const onCloseAddFieldFlyout = useCallback(() => { - setCustomFieldFlyoutVisibility(false); + setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); - }, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]); + }, [setFlyOutVisibility, setCustomFieldToEdit]); + + const onFlyoutSave = useCallback( + (data: CustomFieldConfiguration | TemplateConfiguration | null) => { + if (flyoutType === 'customField') { + const updatedCustomFields = addOrReplaceField( + customFields, + data as CustomFieldConfiguration + ); + + persistCaseConfigure({ + connector, + customFields: updatedCustomFields as CustomFieldsConfiguration, + templates, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setFlyOutVisibility({ type: 'customField', visible: false }); + setCustomFieldToEdit(null); - const onSaveCustomField = useCallback( - (customFieldData: CustomFieldConfiguration) => { - const updatedFields = addOrReplaceCustomField(customFields, customFieldData); - persistCaseConfigure({ - connector, - customFields: updatedFields, - id: configurationId, - version: configurationVersion, - closureType, - }); + return; + } - setCustomFieldFlyoutVisibility(false); - setCustomFieldToEdit(null); + if (flyoutType === 'template') { + const { caseFields, ...rest } = data; + const transformedCustomFields = caseFields?.customFields + ? transformCustomFieldsData(caseFields?.customFields, customFields) + : []; + const transformedData = { + ...rest, + caseFields: { + ...caseFields, + customFields: transformedCustomFields, + }, + }; + const updatedTemplates = addOrReplaceField( + templates, + transformedData as TemplateConfiguration + ); + + persistCaseConfigure({ + connector, + customFields, + templates: updatedTemplates as TemplatesConfiguration, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + } }, [ closureType, @@ -312,24 +372,28 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, + flyoutType, ] ); - const CustomFieldAddFlyout = customFieldFlyoutVisible ? ( - <CustomFieldFlyout - isLoading={loadingCaseConfigure || isPersistingConfiguration} - disabled={ - !permissions.create || - !permissions.update || - loadingCaseConfigure || - isPersistingConfiguration - } - customField={customFieldToEdit} - onCloseFlyout={onCloseAddFieldFlyout} - onSaveField={onSaveCustomField} - /> - ) : null; + const AddOrEditFlyout = + flyOutVisibility !== null && flyoutType !== null ? ( + <CommonFlyout + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + type={flyoutType} + data={flyoutType === 'template' ? templateToEdit : customFieldToEdit} + onCloseFlyout={onCloseAddFieldFlyout} + onSaveField={onFlyoutSave} + /> + ) : null; return ( <EuiPageSection restrictWidth={true}> @@ -397,16 +461,31 @@ export const ConfigureCases: React.FC = React.memo(() => { customFields={customFields} isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} - handleAddCustomField={onAddCustomFields} + handleAddCustomField={() => + setFlyOutVisibility({ type: 'customField', visible: true }) + } handleDeleteCustomField={onDeleteCustomField} handleEditCustomField={onEditCustomField} /> </EuiFlexItem> </div> + + <EuiSpacer size="xl" /> + + <div css={sectionWrapperCss}> + <EuiFlexItem grow={false}> + <Templates + templates={templates} + isLoading={isLoadingCaseConfiguration} + disabled={isLoadingCaseConfiguration} + handleAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + /> + </EuiFlexItem> + </div> <EuiSpacer size="xl" /> {ConnectorAddFlyout} {ConnectorEditFlyout} - {CustomFieldAddFlyout} + {AddOrEditFlyout} </div> </EuiPageBody> </EuiPageSection> diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index e10f6fcad2fb98..ec7181a766362d 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -160,3 +160,14 @@ export const CASES_WEBHOOK_MAPPINGS = i18n.translate( 'Webhook - Case Management field mappings are configured in the connector settings in the third-party REST API JSON.', } ); + +export const ADD_CUSTOM_FIELD = i18n.translate( + 'xpack.cases.configureCases.customFields.addCustomField', + { + defaultMessage: 'Add field', + } +); + +export const CRATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.flyoutTitle', { + defaultMessage: 'Create template', +}); diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index 1e8464dc1a2ed5..48f3b79338aa11 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -38,6 +38,7 @@ import { useIsUserTyping } from '../../common/use_is_user_typing'; interface Props { isLoading: boolean; + path?: string; } interface FieldProps { @@ -200,7 +201,7 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( AssigneesFieldComponent.displayName = 'AssigneesFieldComponent'; -const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { +const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm, path }) => { const { owner: owners } = useCasesContext(); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); @@ -245,7 +246,7 @@ const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { return ( <UseField - path="assignees" + path={path ?? 'assignees'} config={getConfig()} component={AssigneesFieldComponent} componentProps={{ diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/create/category.tsx index 879a8dfb9bbea4..5d00a11f4e20a0 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/create/category.tsx @@ -12,9 +12,10 @@ import { OptionalFieldLabel } from './optional_field_label'; interface Props { isLoading: boolean; + path?: string; } -const CategoryComponent: React.FC<Props> = ({ isLoading }) => { +const CategoryComponent: React.FC<Props> = ({ isLoading, path }) => { const { isLoading: isLoadingCategories, data: categories = [] } = useGetCategories(); return ( @@ -22,6 +23,7 @@ const CategoryComponent: React.FC<Props> = ({ isLoading }) => { isLoading={isLoading || isLoadingCategories} availableCategories={categories} formRowProps={{ labelAppend: OptionalFieldLabel }} + path={path} /> ); }; diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx index 28cebde65db27e..d87d3cbfdc10a4 100644 --- a/x-pack/plugins/cases/public/components/create/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/create/custom_fields.tsx @@ -19,9 +19,11 @@ import { getConfigurationByOwner } from '../../containers/configure/utils'; interface Props { isLoading: boolean; + path?: string; + setAsOptional?: boolean; } -const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { +const CustomFieldsComponent: React.FC<Props> = ({ isLoading, path, setAsOptional }) => { const { owner } = useCasesContext(); const [{ selectedOwner }] = useFormData<{ selectedOwner: string }>({ watch: ['selectedOwner'] }); const { data: configurations, isLoading: isLoadingCaseConfiguration } = @@ -54,6 +56,8 @@ const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { isLoading={isLoading || isLoadingCaseConfiguration} customFieldConfiguration={customField} key={customField.key} + path={path} + setAsOptional={setAsOptional} /> ); } diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 5c512e701c123b..4409ef9d8d4d2e 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -13,18 +13,19 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; draftStorageKey: string; + path?: string; } export const fieldName = 'description'; -const DescriptionComponent: React.FC<Props> = ({ isLoading, draftStorageKey }) => { +const DescriptionComponent: React.FC<Props> = ({ isLoading, draftStorageKey, path }) => { const [{ title, tags }] = useFormData({ watch: ['title', 'tags'] }); const editorRef = useRef<Record<string, unknown>>(); const disabledUiPlugins = [LensPluginId]; return ( <UseField - path={fieldName} + path={path ?? fieldName} component={MarkdownEditorForm} componentProps={{ id: fieldName, diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx index b65ec7f6a63507..e69980c02d1caf 100644 --- a/x-pack/plugins/cases/public/components/create/severity.tsx +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -18,11 +18,12 @@ import { SEVERITY_TITLE } from '../severity/translations'; interface Props { isLoading: boolean; + path?: string; } -const SeverityComponent: React.FC<Props> = ({ isLoading }) => ( +const SeverityComponent: React.FC<Props> = ({ isLoading, path }) => ( <UseField<CaseSeverity> - path={'severity'} + path={path ?? 'severity'} componentProps={{ isLoading, }} diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index 1a189de3e17ec0..29782f391168c9 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -14,13 +14,14 @@ const CommonUseField = getUseField({ component: Field }); interface Props { isLoading: boolean; + path?: string; } -const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading }) => { +const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading, path }) => { const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); return ( <CommonUseField - path="syncAlerts" + path={path ?? 'syncAlerts'} componentProps={{ idAria: 'caseSyncAlerts', 'data-test-subj': 'caseSyncAlerts', diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index f3d4319dfea372..9744e198fbac3f 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -7,18 +7,16 @@ import React, { memo, useMemo } from 'react'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { useGetTags } from '../../containers/use_get_tags'; import * as i18n from './translations'; - -const CommonUseField = getUseField({ component: Field }); - interface Props { isLoading: boolean; + path?: string; } -const TagsComponent: React.FC<Props> = ({ isLoading }) => { +const TagsComponent: React.FC<Props> = ({ isLoading, path }) => { const { data: tagOptions = [], isLoading: isLoadingTags } = useGetTags(); const options = useMemo( () => @@ -29,8 +27,10 @@ const TagsComponent: React.FC<Props> = ({ isLoading }) => { ); return ( - <CommonUseField - path="tags" + <UseField + path={path ?? 'tags'} + component={ComboBoxField} + defaultValue={[]} componentProps={{ idAria: 'caseTags', 'data-test-subj': 'caseTags', diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx index 35de4c7a41ccb7..2807da9d30eace 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -12,11 +12,12 @@ const CommonUseField = getUseField({ component: Field }); interface Props { isLoading: boolean; + path?: string; } -const TitleComponent: React.FC<Props> = ({ isLoading }) => ( +const TitleComponent: React.FC<Props> = ({ isLoading, path }) => ( <CommonUseField - path="title" + path={path ?? 'title'} componentProps={{ idAria: 'caseTitle', 'data-test-subj': 'caseTitle', 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 aaab2043fb3325..ae34f49f790a9a 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 @@ -11,21 +11,26 @@ import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import type { CaseCustomFieldText } from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; import { getTextFieldConfig } from './config'; +import { OptionalFieldLabel } from '../../create/optional_field_label'; const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, + path, + setAsOptional, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ - required, + required: setAsOptional ? false : required, label, ...(defaultValue && { defaultValue: String(defaultValue) }), }); + const newPath = path ?? 'customFields'; + return ( <UseField - path={`customFields.${key}`} + path={`${newPath}.${key}`} config={config} component={TextField} label={label} @@ -35,6 +40,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ fullWidth: true, disabled: isLoading, isLoading, + labelAppend: setAsOptional ? OptionalFieldLabel : null, }, }} /> 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 2d3f51bc4f678a..23abeb37ead40c 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 @@ -14,12 +14,15 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, + path, }) => { const { key, label, defaultValue } = customFieldConfiguration; + const newPath = path ?? 'customFields'; + return ( <UseField - path={`customFields.${key}`} + path={`${newPath}.${key}`} component={ToggleField} config={{ defaultValue: defaultValue ? defaultValue : false }} key={key} 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 856ff7e9e1c606..72c96b4bb564d3 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -30,6 +30,8 @@ export interface CustomFieldType<T extends CaseUICustomField> { Create: React.FC<{ customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; + path?: string; + setAsOptional?: boolean; }>; } diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts index ba629a6ea10a4a..5a213196458360 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -5,202 +5,11 @@ * 2.0. */ -import { addOrReplaceCustomField, customFieldSerializer } from './utils'; -import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock'; +import { customFieldSerializer } from './utils'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; -import type { CaseUICustomField } from '../../../common/ui'; describe('utils ', () => { - describe('addOrReplaceCustomField ', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('adds new custom field correctly', async () => { - const fieldToAdd: CaseUICustomField = { - key: 'my_test_key', - type: CustomFieldTypes.TEXT, - value: 'my_test_value', - }; - const res = addOrReplaceCustomField(customFieldsMock, fieldToAdd); - expect(res).toMatchInlineSnapshot( - [...customFieldsMock, fieldToAdd], - ` - Array [ - Object { - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - Object { - "key": "my_test_key", - "type": "text", - "value": "my_test_value", - }, - ] - ` - ); - }); - - it('updates existing custom field correctly', async () => { - const fieldToUpdate = { - ...customFieldsMock[0], - field: { value: ['My text test value 1!!!'] }, - }; - - const res = addOrReplaceCustomField(customFieldsMock, fieldToUpdate as CaseUICustomField); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsMock[1] }, - { ...customFieldsMock[2] }, - { ...customFieldsMock[3] }, - ], - ` - Array [ - Object { - "field": Object { - "value": Array [ - "My text test value 1!!!", - ], - }, - "key": "test_key_1", - "type": "text", - "value": "My text test value 1", - }, - Object { - "key": "test_key_2", - "type": "toggle", - "value": true, - }, - Object { - "key": "test_key_3", - "type": "text", - "value": null, - }, - Object { - "key": "test_key_4", - "type": "toggle", - "value": null, - }, - ] - ` - ); - }); - - it('adds new custom field configuration correctly', async () => { - const fieldToAdd = { - key: 'my_test_key', - type: CustomFieldTypes.TEXT, - label: 'my_test_label', - required: true, - }; - const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToAdd); - expect(res).toMatchInlineSnapshot( - [...customFieldsConfigurationMock, fieldToAdd], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - Object { - "key": "my_test_key", - "label": "my_test_label", - "required": true, - "type": "text", - }, - ] - ` - ); - }); - - it('updates existing custom field config correctly', async () => { - const fieldToUpdate = { - ...customFieldsConfigurationMock[0], - label: `${customFieldsConfigurationMock[0].label}!!!`, - }; - - const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToUpdate); - expect(res).toMatchInlineSnapshot( - [ - { ...fieldToUpdate }, - { ...customFieldsConfigurationMock[1] }, - { ...customFieldsConfigurationMock[2] }, - { ...customFieldsConfigurationMock[3] }, - ], - ` - Array [ - Object { - "defaultValue": "My default value", - "key": "test_key_1", - "label": "My test label 1!!!", - "required": true, - "type": "text", - }, - Object { - "defaultValue": true, - "key": "test_key_2", - "label": "My test label 2", - "required": true, - "type": "toggle", - }, - Object { - "key": "test_key_3", - "label": "My test label 3", - "required": false, - "type": "text", - }, - Object { - "key": "test_key_4", - "label": "My test label 4", - "required": false, - "type": "toggle", - }, - ] - ` - ); - }); - }); - describe('customFieldSerializer ', () => { it('serializes the data correctly if the default value is a normal string', async () => { const customField = { diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts index bea01a3761bd01..e0abfb3e0411e9 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -8,27 +8,8 @@ import { isEmptyString } from '@kbn/es-ui-shared-plugin/static/validators/string'; import { isString } from 'lodash'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; - -export const addOrReplaceCustomField = <T extends { key: string }>( - customFields: T[], - customFieldToAdd: T -): T[] => { - const foundCustomFieldIndex = customFields.findIndex( - (customField) => customField.key === customFieldToAdd.key - ); - - if (foundCustomFieldIndex === -1) { - return [...customFields, customFieldToAdd]; - } - - return customFields.map((customField) => { - if (customField.key !== customFieldToAdd.key) { - return customField; - } - - return customFieldToAdd; - }); -}; +import type { CasesConfigurationUI, CaseUI, CaseUICustomField } from '../../containers/types'; +import { convertCustomFieldValue } from '../utils'; export const customFieldSerializer = ( field: CustomFieldConfiguration @@ -41,3 +22,27 @@ export const customFieldSerializer = ( return field; }; + +export const transformCustomFieldsData = ( + customFields: Record<string, string | boolean>, + selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] +) => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !selectedCustomFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = selectedCustomFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; +}; diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx new file mode 100644 index 00000000000000..1a6d376e7314ad --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import type { TemplateConfiguration } from '../../../common/types/domain'; +import { templateFormSerializer } from './utils'; + +export interface TemplateFormState { + isValid: boolean | undefined; + submit: FormHook<TemplateConfiguration>['submit']; +} + +interface Props { + onChange: (state: TemplateFormState) => void; + initialValue: TemplateConfiguration | null; +} + +const FormComponent: React.FC<Props> = ({ onChange, initialValue }) => { + const keyDefaultValue = useMemo(() => uuidv4(), []); + + const { form } = useForm({ + defaultValue: initialValue ?? { + key: keyDefaultValue, + name: '', + description: '', + caseFields: null, + }, + options: { stripEmptyFields: false }, + schema, + serializer: templateFormSerializer, + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( + <Form form={form}> + <FormFields isSubmitting={isSubmitting} isEditMode={Boolean(initialValue)} /> + </Form> + ); +}; + +FormComponent.displayName = 'TemplateForm'; + +export const TemplateForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx new file mode 100644 index 00000000000000..4968f86116d832 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiSteps } from '@elastic/eui'; +import { CaseFormFields } from '../case_form_fields'; +import * as i18n from './translations'; + +interface FormFieldsProps { + isSubmitting?: boolean; + isEditMode?: boolean; +} + +const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting, isEditMode }) => { + const firstStep = useMemo( + () => ({ + title: i18n.TEMPLATE_FIELDS, + children: ( + <> + <UseField + path="name" + component={TextField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'template-name-input', + fullWidth: true, + autoFocus: true, + isLoading: isSubmitting, + }, + }} + /> + <UseField + path="description" + component={TextField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + autoFocus: true, + isLoading: isSubmitting, + }, + }} + /> + </> + ), + }), + [isSubmitting] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.CASE_FIELDS, + children: <CaseFormFields />, + }), + [] + ); + + const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + + return ( + <> + <UseField path="key" component={HiddenField} /> + + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'template-creation-form-steps'} + /> + </> + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx new file mode 100644 index 00000000000000..49824367e4cad7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -0,0 +1,111 @@ +/* + * 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 } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { useCasesContext } from '../cases_context/use_cases_context'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; +import * as i18n from './translations'; +import { TemplatesList } from './templates_list'; + +interface Props { + disabled: boolean; + isLoading: boolean; + templates: CasesConfigurationUITemplate[]; + handleAddTemplate: () => void; +} + +const TemplatesComponent: React.FC<Props> = ({ + disabled, + isLoading, + templates, + handleAddTemplate, +}) => { + const { permissions } = useCasesContext(); + const canAddTemplates = permissions.create && permissions.update; + + const onAddCustomField = useCallback(() => { + // if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) { + // setError(true); + // return; + // } + + handleAddTemplate(); + // setError(false); + }, [handleAddTemplate]); + + return ( + <EuiDescribedFormGroup + fullWidth + title={ + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem grow={false}>{i18n.TEMPLATE_TITLE}</EuiFlexItem> + <EuiFlexItem grow={false}> + <ExperimentalBadge /> + </EuiFlexItem> + </EuiFlexGroup> + } + description={<p>{i18n.TEMPLATE_DESCRIPTION}</p>} + data-test-subj="templates-form-group" + > + <EuiPanel paddingSize="s" color="subdued" hasBorder={false} hasShadow={false}> + {templates.length ? ( + <> + <TemplatesList templates={templates} /> + {/* {error ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiText color="danger"> + {i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} */} + </> + ) : null} + <EuiSpacer size="m" /> + {!templates.length ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false} data-test-subj="empty-templates"> + {i18n.NO_TEMPLATES} + <EuiSpacer size="m" /> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + {canAddTemplates ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + isLoading={isLoading} + isDisabled={disabled} + size="s" + onClick={onAddCustomField} + iconType="plusInCircle" + data-test-subj="add-template" + > + {i18n.ADD_TEMPLATE} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + ) : null} + </EuiPanel> + </EuiDescribedFormGroup> + ); +}; + +TemplatesComponent.displayName = 'Templates'; + +export const Templates = React.memo(TemplatesComponent); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx new file mode 100644 index 00000000000000..57fdf426ffc21c --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import { OptionalFieldLabel } from '../create/optional_field_label'; +import { SEVERITY_TITLE } from '../severity/translations'; +import * as i18n from './translations'; + +const { emptyField, maxLengthField } = fieldValidators; +const isInvalidTag = (value: string) => value.trim() === ''; + +const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; + +export const schema = { + key: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD('key')), + }, + ], + }, + name: { + label: i18n.TEMPLATE_NAME, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.TEMPLATE_NAME)), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.DESCRIPTION)), + }, + ], + }, + caseFields: { + title: { + label: i18n.NAME, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + }), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + }), + }, + ], + }, + tags: { + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => { + if ( + (!Array.isArray(value) && isInvalidTag(value)) || + (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) + ) { + return { + message: i18n.TAGS_EMPTY_ERROR, + }; + } + }, + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => { + if ( + (!Array.isArray(value) && isTagCharactersInLimit(value)) || + (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) + ) { + return { + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + }; + } + }, + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => { + if (Array.isArray(value) && value.length > MAX_TAGS_PER_CASE) { + return { + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + }; + } + }, + }, + ], + }, + severity: { + label: SEVERITY_TITLE, + labelAppend: OptionalFieldLabel, + }, + // connectorId: { + // type: FIELD_TYPES.SUPER_SELECT, + // label: i18n.CONNECTORS, + // defaultValue: 'none', + // labelAppend: OptionalFieldLabel, + // }, + assignees: { + labelAppend: OptionalFieldLabel, + }, + category: { + labelAppend: OptionalFieldLabel, + }, + }, +}; diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx new file mode 100644 index 00000000000000..dbadac1f4aed9f --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; + +export interface Props { + templates: CasesConfigurationUITemplate[]; + // onDeleteCustomField: (key: string) => void; + // onEditCustomField: (key: string) => void; +} + +const TemplatesListComponent: React.FC<Props> = (props) => { + const { templates } = props; + const [selectedItem, setSelectedItem] = useState<CasesConfigurationUITemplate | null>(null); + + return templates.length ? ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup justifyContent="flexStart" data-test-subj="templates-list"> + <EuiFlexItem> + {templates.map((template) => ( + <React.Fragment key={template.key}> + <EuiPanel + paddingSize="s" + data-test-subj={`custom-field-${template.key}`} + hasShadow={false} + > + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={true}> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText> + <h4>{template.name}</h4> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + {/* <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="flexEnd" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${customField.key}-custom-field-edit`} + aria-label={`${customField.key}-custom-field-edit`} + iconType="pencil" + color="primary" + onClick={() => onEditCustomField(customField.key)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${customField.key}-custom-field-delete`} + aria-label={`${customField.key}-custom-field-delete`} + iconType="minusInCircle" + color="danger" + onClick={() => setSelectedItem(customField)} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> */} + </EuiFlexGroup> + </EuiPanel> + <EuiSpacer size="s" /> + </React.Fragment> + ))} + </EuiFlexItem> + {/* {showModal && selectedItem ? ( + <DeleteConfirmationModal + label={selectedItem.label} + onCancel={onCancel} + onConfirm={onConfirm} + /> + ) : null} */} + </EuiFlexGroup> + </> + ) : null; +}; + +TemplatesListComponent.displayName = 'TemplatesList'; + +export const TemplatesList = React.memo(TemplatesListComponent); diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts new file mode 100644 index 00000000000000..3970d388a726b8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const TEMPLATE_TITLE = i18n.translate('xpack.cases.templates.title', { + defaultMessage: 'Templates', +}); + +export const TEMPLATE_DESCRIPTION = i18n.translate('xpack.cases.templates.description', { + defaultMessage: 'Template description.', +}); + +export const NO_TEMPLATES = i18n.translate('xpack.cases.templates.noTemplates', { + defaultMessage: 'You do not have any templates yet', +}); + +export const ADD_TEMPLATE = i18n.translate('xpack.cases.templates.addTemplate', { + defaultMessage: 'Add template', +}); + +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.templates.createTemplate', { + defaultMessage: 'Create template', +}); + +export const REQUIRED = i18n.translate('xpack.cases.templates.required', { + defaultMessage: 'Required', +}); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.templates.requiredField', { + values: { fieldName }, + defaultMessage: 'A {fieldName} is required.', + }); + +export const TEMPLATE_NAME = i18n.translate('xpack.cases.templates.templateName', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_FIELDS = i18n.translate('xpack.cases.templates.templateFields', { + defaultMessage: 'Template fields', +}); + +export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { + defaultMessage: 'Case fields', +}); + +// export const SAVE = i18n.translate('xpack.cases.templates.saveTemplate', { +// defaultMessage: 'Save', +// }); diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts new file mode 100644 index 00000000000000..1bc87aa77cef83 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -0,0 +1,15 @@ +/* + * 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 { TemplateConfiguration } from '../../../common/types/domain'; + +export interface TemplateConfigurationUI + extends Pick<TemplateConfiguration, 'key' | 'name' | 'description'> { + caseFields: Omit<TemplateConfiguration['caseFields'], 'customFields'> & { + customFields?: Record<string, string | boolean>; + }; +} diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts new file mode 100644 index 00000000000000..ea46422c7ce65c --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TemplateConfiguration } from '../../../common/types/domain'; +import { isEmptyValue } from '../utils'; + +export const removeEmptyFields = ( + fields: TemplateConfiguration['caseFields'] | Record<string, string | boolean> | null | undefined +): TemplateConfiguration['caseFields'] => { + if (fields) { + return Object.entries(fields).reduce((acc, [key, value]) => { + let initialValue = {}; + + if (key === 'customFields') { + const nonEmptyFields = removeEmptyFields(value) ?? {}; + + if (Object.entries(nonEmptyFields).length > 0) { + initialValue = { + customFields: nonEmptyFields, + }; + } + } + + if (key !== 'customFields' && !isEmptyValue(value)) { + initialValue = { [key]: value }; + } + + return { + ...acc, + ...initialValue, + }; + }, {}); + } + + return null; +}; + +export const templateFormSerializer = <T extends TemplateConfiguration>(data: T): T => { + if (data.caseFields) { + const serializedFields = removeEmptyFields(data.caseFields); + + return { + ...data, + caseFields: serializedFields as TemplateConfiguration['caseFields'], + }; + } + + return data; +}; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 0e7cd9fb03b35a..8f3ef31bdfc4ce 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,7 +7,14 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { elasticUser, getCaseUsersMockResponse } from '../containers/mock'; +import { + customFieldsConfigurationMock, + customFieldsMock, + elasticUser, + getCaseUsersMockResponse, +} from '../containers/mock'; +import type { CaseUICustomField } from '../containers/types'; +import { CustomFieldTypes } from '../../common/types/domain/custom_field/v1'; import { connectorDeprecationValidator, convertEmptyValuesToNull, @@ -21,6 +28,7 @@ import { stringifyToURL, parseCaseUsers, convertCustomFieldValue, + addOrReplaceField, } from './utils'; describe('Utils', () => { @@ -528,4 +536,193 @@ describe('Utils', () => { expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); }); }); + + describe('addOrReplaceField ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds new custom field correctly', async () => { + const fieldToAdd: CaseUICustomField = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + value: 'my_test_value', + }; + const res = addOrReplaceField(customFieldsMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": "My text test value 1", + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "test_key_3", + "type": "text", + "value": null, + }, + Object { + "key": "test_key_4", + "type": "toggle", + "value": null, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": "my_test_value", + }, + ] + ` + ); + }); + + it('updates existing custom field correctly', async () => { + const fieldToUpdate = { + ...customFieldsMock[0], + field: { value: ['My text test value 1!!!'] }, + }; + + const res = 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, + }, + ] + ` + ); + }); + + it('adds new custom field configuration correctly', async () => { + const fieldToAdd = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + label: 'my_test_label', + required: true, + }; + const res = addOrReplaceField(customFieldsConfigurationMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsConfigurationMock, fieldToAdd], + ` + Array [ + Object { + "defaultValue": "My default value", + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "defaultValue": true, + "key": "test_key_2", + "label": "My test label 2", + "required": true, + "type": "toggle", + }, + Object { + "key": "test_key_3", + "label": "My test label 3", + "required": false, + "type": "text", + }, + Object { + "key": "test_key_4", + "label": "My test label 4", + "required": false, + "type": "toggle", + }, + Object { + "key": "my_test_key", + "label": "my_test_label", + "required": true, + "type": "text", + }, + ] + ` + ); + }); + + it('updates existing custom field config correctly', async () => { + const fieldToUpdate = { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!!`, + }; + + const res = 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", + }, + ] + ` + ); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 13bff3b48fdc99..b851368368c223 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -235,3 +235,19 @@ export const convertCustomFieldValue = (value: string | boolean) => { return value; }; + +export const addOrReplaceField = <T extends { key: string }>(fields: T[], fieldToAdd: T): T[] => { + const foundFieldIndex = fields.findIndex((field) => field.key === fieldToAdd.key); + + if (foundFieldIndex === -1) { + return [...fields, fieldToAdd]; + } + + return fields.map((field) => { + if (field.key !== fieldToAdd.key) { + return field; + } + + return fieldToAdd; + }); +}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx index 509b0e72cd1fc2..47816321d684ae 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -38,6 +38,7 @@ describe('useCreateAttachments', () => { type: ConnectorTypes.none, }, customFields: [], + templates: [], version: '', id: '', }; @@ -69,6 +70,7 @@ describe('useCreateAttachments', () => { connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], owner: 'securitySolution', + templates: [], }); }); @@ -91,6 +93,7 @@ describe('useCreateAttachments', () => { closure_type: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], owner: 'securitySolution', }); }); @@ -114,6 +117,7 @@ describe('useCreateAttachments', () => { closure_type: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], version: 'test-version', }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx index 95162d23aa3916..dc9bed95d1df83 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -27,12 +27,13 @@ export const usePersistConfiguration = () => { const { showErrorToast, showSuccessToast } = useCasesToast(); return useMutation( - ({ id, version, closureType, customFields, connector }: Request) => { + ({ id, version, closureType, customFields, templates, connector }: Request) => { if (isEmpty(id) || isEmpty(version)) { return postCaseConfigure({ closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], owner: owner[0], }); } @@ -42,6 +43,7 @@ export const usePersistConfiguration = () => { closure_type: closureType, connector, customFields: customFields ?? [], + templates: templates ?? [], }); }, { From 9be6c681525b933c8f85b9cbb3806fad16044b16 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 16 May 2024 14:01:02 +0200 Subject: [PATCH 02/28] add connector field --- .../cases/common/types/domain/case/v1.ts | 1 + .../components/configure_cases/flyout.tsx | 23 +++- .../components/configure_cases/index.tsx | 38 ++++--- .../public/components/create/connector.tsx | 14 ++- .../cases/public/components/create/form.tsx | 1 + .../public/components/custom_fields/form.tsx | 2 +- .../public/components/templates/connector.tsx | 100 ++++++++++++++++++ .../public/components/templates/form.tsx | 30 ++++-- .../components/templates/form_fields.tsx | 36 ++++++- .../public/components/templates/schema.tsx | 24 +++-- .../components/templates/translations.ts | 4 + .../public/components/templates/types.ts | 22 ++-- .../public/components/templates/utils.ts | 41 +++++-- 13 files changed, 277 insertions(+), 59 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/templates/connector.tsx diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index d5dfdcd5ee1748..83d48df363bd27 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -161,3 +161,4 @@ export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>; export type CaseSettings = rt.TypeOf<typeof CaseSettingsRt>; export type RelatedCase = rt.TypeOf<typeof RelatedCaseRt>; export type AttachmentTotals = rt.TypeOf<typeof AttachmentTotalsRt>; +export type CaseBaseOptionalFields = rt.TypeOf<typeof CaseBaseOptionalFieldsRt>; diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 3e4d453861b5a5..8b04baec9e04d5 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -21,17 +21,26 @@ import type { CustomFieldFormState } from '../custom_fields/form'; import type { TemplateFormState } from '../templates/form'; import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; -import type { CustomFieldConfiguration, TemplateConfiguration } from '../../../common/types/domain'; +import type { + ActionConnector, + CustomFieldConfiguration, + TemplateConfiguration, +} from '../../../common/types/domain'; import * as i18n from './translations'; +import type { TemplateFormProps } from '../templates/types'; +import { CaseActionConnector } from '../types'; +import { CasesConfigurationUI } from '../../containers/types'; export interface FlyoutProps { disabled: boolean; isLoading: boolean; onCloseFlyout: () => void; - onSaveField: (data: CustomFieldConfiguration | TemplateConfiguration | null) => void; + onSaveField: (data: CustomFieldConfiguration | TemplateFormProps | null) => void; data: CustomFieldConfiguration | TemplateConfiguration | null; type: 'customField' | 'template'; + connectors: ActionConnector[]; + configurationConnector: CasesConfigurationUI['connector']; } const FlyoutComponent: React.FC<FlyoutProps> = ({ @@ -41,6 +50,8 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ disabled, data: initialValue, type, + connectors, + configurationConnector, }) => { const dataTestSubj = `${type}Flyout`; @@ -48,7 +59,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ isValid: undefined, submit: async () => ({ isValid: false, - data: null, + data: {}, }), }); @@ -58,7 +69,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ const { isValid, data } = await submit(); if (isValid) { - onSaveField(data as CustomFieldConfiguration | TemplateConfiguration | null); + onSaveField(data as CustomFieldConfiguration | TemplateFormProps | null); } }, [onSaveField, submit]); @@ -81,7 +92,9 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ {type === 'template' ? ( <TemplateForm onChange={setFormState} - initialValue={initialValue as TemplateConfiguration} + initialValue={initialValue as TemplateFormProps} + connectors={connectors} + configurationConnector={configurationConnector} /> ) : null} </EuiFlyoutBody> 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 f7bb72c0e8ef08..6a4981899a2099 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -26,7 +26,6 @@ import type { CustomFieldConfiguration, CustomFieldsConfiguration, TemplateConfiguration, - TemplatesConfiguration, } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; @@ -49,6 +48,7 @@ import { usePersistConfiguration } from '../../containers/configure/use_persist_ import { transformCustomFieldsData } from '../custom_fields/utils'; import { useLicense } from '../../common/use_license'; import { Templates } from '../templates'; +import type { TemplateFormProps } from '../templates/types'; const sectionWrapperCss = css` box-sizing: content-box; @@ -142,6 +142,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -152,6 +153,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigureAsync, closureType, customFields, + templates, configurationId, configurationVersion, onConnectorUpdated, @@ -193,6 +195,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connector: caseConnector, closureType, customFields, + templates, id: configurationId, version: configurationVersion, }); @@ -202,6 +205,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure, closureType, customFields, + templates, configurationId, configurationVersion, ] @@ -212,12 +216,20 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector, customFields, + templates, id: configurationId, version: configurationVersion, closureType: type, }); }, - [configurationId, configurationVersion, connector, customFields, persistCaseConfigure] + [ + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] ); useEffect(() => { @@ -269,11 +281,6 @@ export const ConfigureCases: React.FC = React.memo(() => { [connector.id, editedConnectorItem, flyOutVisibility] ); - // const onAddCustomFields = useCallback(() => { - // // setCustomFieldFlyoutVisibility(true); - // setFlyOutVisibility({ type: 'customField', visible: true }); - // }, [setFlyOutVisibility]); - const onDeleteCustomField = useCallback( (key: string) => { const remainingCustomFields = customFields.filter((field) => field.key !== key); @@ -281,6 +288,7 @@ export const ConfigureCases: React.FC = React.memo(() => { persistCaseConfigure({ connector, customFields: [...remainingCustomFields], + templates, id: configurationId, version: configurationVersion, closureType, @@ -292,6 +300,7 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationVersion, connector, customFields, + templates, persistCaseConfigure, ] ); @@ -314,7 +323,7 @@ export const ConfigureCases: React.FC = React.memo(() => { }, [setFlyOutVisibility, setCustomFieldToEdit]); const onFlyoutSave = useCallback( - (data: CustomFieldConfiguration | TemplateConfiguration | null) => { + (data: CustomFieldConfiguration | TemplateFormProps | null) => { if (flyoutType === 'customField') { const updatedCustomFields = addOrReplaceField( customFields, @@ -337,26 +346,23 @@ export const ConfigureCases: React.FC = React.memo(() => { } if (flyoutType === 'template') { - const { caseFields, ...rest } = data; + const { caseFields, ...rest } = data as TemplateFormProps; const transformedCustomFields = caseFields?.customFields ? transformCustomFieldsData(caseFields?.customFields, customFields) : []; - const transformedData = { + const transformedData: TemplateConfiguration = { ...rest, caseFields: { ...caseFields, customFields: transformedCustomFields, }, }; - const updatedTemplates = addOrReplaceField( - templates, - transformedData as TemplateConfiguration - ); + const updatedTemplates = addOrReplaceField(templates, transformedData); persistCaseConfigure({ connector, customFields, - templates: updatedTemplates as TemplatesConfiguration, + templates: updatedTemplates, id: configurationId, version: configurationVersion, closureType, @@ -390,6 +396,8 @@ export const ConfigureCases: React.FC = React.memo(() => { } type={flyoutType} data={flyoutType === 'template' ? templateToEdit : customFieldToEdit} + connectors={connectors ?? []} + configurationConnector={connector} onCloseFlyout={onCloseAddFieldFlyout} onSaveField={onFlyoutSave} /> diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 39e04f7bc0be32..4660361b60bb71 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -8,7 +8,7 @@ import React, { memo, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; @@ -24,9 +24,15 @@ interface Props { connectors: ActionConnector[]; isLoading: boolean; isLoadingConnectors: boolean; + path?: string; } -const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingConnectors }) => { +const ConnectorComponent: React.FC<Props> = ({ + connectors, + isLoading, + isLoadingConnectors, + path, +}) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; @@ -57,11 +63,13 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingC ); } + console.log('connector component', { defaultConnectorId, path, connectors, connector }); + return ( <EuiFlexGroup> <EuiFlexItem> <UseField - path="connectorId" + path={path ?? 'connectorId'} config={connectorIdConfig} component={ConnectorSelector} defaultValue={defaultConnectorId} diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 4c95b6e11a11a3..4dd776b887d8f9 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -48,6 +48,7 @@ import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; import { Category } from './category'; import { CustomFields } from './custom_fields'; +import { schema } from './schema'; const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => big diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx index 230b947db854dd..9b7596075b01b5 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -18,7 +18,7 @@ import { customFieldSerializer } from './utils'; export interface CustomFieldFormState { isValid: boolean | undefined; - submit: FormHook<CustomFieldConfiguration>['submit']; + submit: FormHook<CustomFieldConfiguration | {}>['submit']; } interface Props { diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx new file mode 100644 index 00000000000000..8486822e0d0c49 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/connector.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, { memo, useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { ActionConnector } from '../../../common/types/domain'; +import { ConnectorSelector } from '../connector_selector/form'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { getConnectorById, getConnectorsFormValidators } from '../utils'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; +import * as i18n from '../../common/translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { TemplateFormProps } from '../templates/types'; +import { CasesConfigurationUI } from '../../containers/types'; + +interface Props { + connectors: ActionConnector[]; + isLoading: boolean; + path?: string; + schema: FormSchema<TemplateFormProps>; + configurationConnector: CasesConfigurationUI['connector']; +} + +const ConnectorComponent: React.FC<Props> = ({ + connectors, + isLoading, + path, + schema, + configurationConnector, +}) => { + const [{ connectorId }] = useFormData({ watch: ['caseFields.connectorId'] }); + const connector = getConnectorById(connectorId, connectors) ?? null; + + const { actions } = useApplicationCapabilities(); + const { permissions } = useCasesContext(); + const hasReadPermissions = permissions.connectors && actions.read; + + const defaultConnectorId = useMemo(() => { + return connectors.some((c) => c.id === configurationConnector.id) + ? configurationConnector.id + : 'none'; + }, [configurationConnector.id, connectors]); + + const connectorIdConfig = getConnectorsFormValidators({ + config: schema.caseFields?.connectorId as FieldConfig, + connectors, + }); + + if (!hasReadPermissions) { + return ( + <EuiText data-test-subj="create-case-connector-permissions-error-msg" size="s"> + <span>{i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG}</span> + </EuiText> + ); + } + + console.log('connector component', { + defaultConnectorId, + path, + connectors, + connector, + connectorId, + }); + + return ( + <EuiFlexGroup> + <EuiFlexItem> + <UseField + path={path ?? 'connectorId'} + config={connectorIdConfig} + component={ConnectorSelector} + defaultValue={defaultConnectorId} + componentProps={{ + connectors, + dataTestSubj: 'caseConnectors', + disabled: isLoading, + idAria: 'caseConnectors', + isLoading: isLoading, + // handleChange: onConnectorChange, + }} + /> + </EuiFlexItem> + <EuiFlexItem> + <ConnectorFieldsForm connector={connector} /> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +ConnectorComponent.displayName = 'ConnectorComponent'; + +export const Connector = memo(ConnectorComponent); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 1a6d376e7314ad..b91b45781261ca 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -9,23 +9,31 @@ import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_l import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; - +import type { ActionConnector } from '../../../common/types/domain'; import { schema } from './schema'; import { FormFields } from './form_fields'; -import type { TemplateConfiguration } from '../../../common/types/domain'; -import { templateFormSerializer } from './utils'; +import { templateDeserializer, templateSerializer } from './utils'; +import type { TemplateFormProps } from './types'; +import { CasesConfigurationUI } from '../../containers/types'; export interface TemplateFormState { isValid: boolean | undefined; - submit: FormHook<TemplateConfiguration>['submit']; + submit: FormHook<TemplateFormProps | {}>['submit']; } interface Props { onChange: (state: TemplateFormState) => void; - initialValue: TemplateConfiguration | null; + initialValue: TemplateFormProps | null; + connectors: ActionConnector[]; + configurationConnector: CasesConfigurationUI['connector']; } -const FormComponent: React.FC<Props> = ({ onChange, initialValue }) => { +const FormComponent: React.FC<Props> = ({ + onChange, + initialValue, + connectors, + configurationConnector, +}) => { const keyDefaultValue = useMemo(() => uuidv4(), []); const { form } = useForm({ @@ -37,7 +45,8 @@ const FormComponent: React.FC<Props> = ({ onChange, initialValue }) => { }, options: { stripEmptyFields: false }, schema, - serializer: templateFormSerializer, + serializer: templateSerializer, + deserializer: templateDeserializer, }); const { submit, isValid, isSubmitting } = form; @@ -50,7 +59,12 @@ const FormComponent: React.FC<Props> = ({ onChange, initialValue }) => { return ( <Form form={form}> - <FormFields isSubmitting={isSubmitting} isEditMode={Boolean(initialValue)} /> + <FormFields + isSubmitting={isSubmitting} + isEditMode={Boolean(initialValue)} + connectors={connectors} + configurationConnector={configurationConnector} + /> </Form> ); }; diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 4968f86116d832..2f1294f60f43b0 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -11,13 +11,24 @@ import { TextField, HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/co import { EuiSteps } from '@elastic/eui'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; +import { schema } from './schema'; +import { Connector } from './connector'; +import { ActionConnector } from '../../containers/configure/types'; +import { CasesConfigurationUI } from '../../containers/types'; interface FormFieldsProps { isSubmitting?: boolean; isEditMode?: boolean; + connectors: ActionConnector[]; + configurationConnector: CasesConfigurationUI['connector']; } -const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting, isEditMode }) => { +const FormFieldsComponent: React.FC<FormFieldsProps> = ({ + isSubmitting = false, + isEditMode, + connectors, + configurationConnector, +}) => { const firstStep = useMemo( () => ({ title: i18n.TEMPLATE_FIELDS, @@ -61,7 +72,28 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting, isEditMo [] ); - const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + const thirdStep = useMemo( + () => ({ + title: i18n.CONNECTOR_FIELDS, + children: ( + <div> + <Connector + connectors={connectors} + isLoading={isSubmitting} + configurationConnector={configurationConnector} + path="caseFields.connectorId" + schema={schema} + /> + </div> + ), + }), + [connectors, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, thirdStep], + [firstStep, secondStep, thirdStep] + ); return ( <> diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 57fdf426ffc21c..937f55a6d26f84 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -6,6 +6,7 @@ */ import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { FIELD_TYPES, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { MAX_DESCRIPTION_LENGTH, @@ -16,13 +17,14 @@ import { import { OptionalFieldLabel } from '../create/optional_field_label'; import { SEVERITY_TITLE } from '../severity/translations'; import * as i18n from './translations'; +import type { TemplateFormProps } from './types'; const { emptyField, maxLengthField } = fieldValidators; const isInvalidTag = (value: string) => value.trim() === ''; const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; -export const schema = { +export const schema: FormSchema<TemplateFormProps> = { key: { validations: [ { @@ -119,17 +121,25 @@ export const schema = { label: SEVERITY_TITLE, labelAppend: OptionalFieldLabel, }, - // connectorId: { - // type: FIELD_TYPES.SUPER_SELECT, - // label: i18n.CONNECTORS, - // defaultValue: 'none', - // labelAppend: OptionalFieldLabel, - // }, assignees: { labelAppend: OptionalFieldLabel, }, category: { labelAppend: OptionalFieldLabel, }, + connectorId: { + type: FIELD_TYPES.SUPER_SELECT, + labelAppend: OptionalFieldLabel, + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: { + defaultValue: null, + }, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + defaultValue: true, + labelAppend: OptionalFieldLabel, + }, }, }; diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index 3970d388a726b8..675f8f9fe72f08 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -51,6 +51,10 @@ export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { defaultMessage: 'Case fields', }); +export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { + defaultMessage: 'External Connector Fields', +}); + // export const SAVE = i18n.translate('xpack.cases.templates.saveTemplate', { // defaultMessage: 'Save', // }); diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index 1bc87aa77cef83..567d5e468f83ef 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -5,11 +5,19 @@ * 2.0. */ -import type { TemplateConfiguration } from '../../../common/types/domain'; +import type { + CaseBaseOptionalFields, + ConnectorTypeFields, + TemplateConfiguration, +} from '../../../common/types/domain'; -export interface TemplateConfigurationUI - extends Pick<TemplateConfiguration, 'key' | 'name' | 'description'> { - caseFields: Omit<TemplateConfiguration['caseFields'], 'customFields'> & { - customFields?: Record<string, string | boolean>; - }; -} +export type TemplateFormProps = Omit<TemplateConfiguration, 'caseFields'> & { + caseFields: + | (Omit<CaseBaseOptionalFields, 'customFields' | 'connector' | 'settings'> & { + customFields?: Record<string, string | boolean>; + connectorId?: string; + fields?: ConnectorTypeFields['fields']; + syncAlerts?: boolean; + }) + | null; +}; diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index ea46422c7ce65c..47cfa3fbbddf9d 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -5,27 +5,28 @@ * 2.0. */ -import type { TemplateConfiguration } from '../../../common/types/domain'; -import { isEmptyValue } from '../utils'; +import { ConnectorTypeFields } from '@kbn/cases-plugin/common/types/domain'; +import { getConnectorsFormDeserializer, isEmptyValue } from '../utils'; +import type { TemplateFormProps } from './types'; export const removeEmptyFields = ( - fields: TemplateConfiguration['caseFields'] | Record<string, string | boolean> | null | undefined -): TemplateConfiguration['caseFields'] => { + fields: TemplateFormProps['caseFields'] | Record<string, string | boolean> | null | undefined +): TemplateFormProps['caseFields'] => { if (fields) { return Object.entries(fields).reduce((acc, [key, value]) => { let initialValue = {}; if (key === 'customFields') { - const nonEmptyFields = removeEmptyFields(value) ?? {}; + const nonEmptyFields = removeEmptyFields(value as Record<string, string | boolean>) ?? {}; if (Object.entries(nonEmptyFields).length > 0) { initialValue = { customFields: nonEmptyFields, }; } - } - - if (key !== 'customFields' && !isEmptyValue(value)) { + } else if (key === 'connectorFields' && !isEmptyValue(value)) { + initialValue = { [key]: value }; + } else if (!isEmptyValue(value)) { initialValue = { [key]: value }; } @@ -39,13 +40,31 @@ export const removeEmptyFields = ( return null; }; -export const templateFormSerializer = <T extends TemplateConfiguration>(data: T): T => { - if (data.caseFields) { +export const templateSerializer = <T extends TemplateFormProps | null>(data: T): T => { + if (data !== null && data.caseFields) { + console.log('templateSerializer', { data }); const serializedFields = removeEmptyFields(data.caseFields); return { ...data, - caseFields: serializedFields as TemplateConfiguration['caseFields'], + caseFields: serializedFields as TemplateFormProps['caseFields'], + }; + } + + return data; +}; + +export const templateDeserializer = <T extends TemplateFormProps | null>(data: T): T => { + if (data && data.caseFields) { + const connectorFields = data.caseFields.fields + ? getConnectorsFormDeserializer({ fields: data.caseFields.fields }) + : { fields: {} }; + return { + ...data, + caseFields: { + ...data?.caseFields, + fields: connectorFields?.fields, + }, }; } From edfc999d0baef3fdb00deb0170444b0d45f09969 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 17 May 2024 12:02:16 +0200 Subject: [PATCH 03/28] map connector fields --- .../components/configure_cases/flyout.tsx | 3 +- .../components/configure_cases/index.tsx | 22 +++++++++++--- .../components/connectors/fields_form.tsx | 5 ++-- .../connectors/jira/case_fields.tsx | 18 ++++++----- .../connectors/jira/search_issues.tsx | 5 ++-- .../connectors/resilient/case_fields.tsx | 9 ++++-- .../servicenow_itsm_case_fields.tsx | 17 ++++++----- .../servicenow/servicenow_sir_case_fields.tsx | 15 +++++----- .../public/components/connectors/types.ts | 1 + .../public/components/create/connector.tsx | 14 ++------- .../cases/public/components/create/form.tsx | 1 - .../components/custom_fields/flyout.tsx | 3 +- .../public/components/templates/connector.tsx | 26 +++++----------- .../public/components/templates/form.tsx | 5 ++-- .../components/templates/form_fields.tsx | 8 ++--- .../public/components/templates/schema.tsx | 3 +- .../public/components/templates/utils.ts | 30 +++++-------------- 17 files changed, 85 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 8b04baec9e04d5..80009e79dd522b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -29,8 +29,7 @@ import type { import * as i18n from './translations'; import type { TemplateFormProps } from '../templates/types'; -import { CaseActionConnector } from '../types'; -import { CasesConfigurationUI } from '../../containers/types'; +import type { CasesConfigurationUI } from '../../containers/types'; export interface FlyoutProps { disabled: boolean; 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 6a4981899a2099..db2dea0d7a172f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -323,7 +323,7 @@ export const ConfigureCases: React.FC = React.memo(() => { }, [setFlyOutVisibility, setCustomFieldToEdit]); const onFlyoutSave = useCallback( - (data: CustomFieldConfiguration | TemplateFormProps | null) => { + (data: CustomFieldConfiguration | TemplateFormProps | null | {}) => { if (flyoutType === 'customField') { const updatedCustomFields = addOrReplaceField( customFields, @@ -347,13 +347,26 @@ export const ConfigureCases: React.FC = React.memo(() => { if (flyoutType === 'template') { const { caseFields, ...rest } = data as TemplateFormProps; - const transformedCustomFields = caseFields?.customFields - ? transformCustomFieldsData(caseFields?.customFields, customFields) + const { + connectorId, + fields, + customFields: templateCustomFields, + ...otherCaseFields + } = caseFields ?? {}; + const transformedCustomFields = templateCustomFields + ? transformCustomFieldsData(templateCustomFields, customFields) : []; + const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; + + const transformedConnector = templateConnector + ? normalizeActionConnector(templateConnector, fields) + : getNoneConnector(); + const transformedData: TemplateConfiguration = { ...rest, caseFields: { - ...caseFields, + ...otherCaseFields, + connector: transformedConnector, customFields: transformedCustomFields, }, }; @@ -377,6 +390,7 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationId, configurationVersion, connector, + connectors, customFields, templates, persistCaseConfigure, diff --git a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx index 2fe99f7dff465c..3930b0d64b960e 100644 --- a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -13,9 +13,10 @@ import { getCaseConnectors } from '.'; interface Props { connector: CaseActionConnector | null; + path?: 'caseFields.fields' | 'fields'; } -const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector }) => { +const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector, path = 'fields' }) => { const { caseConnectorsRegistry } = getCaseConnectors(); if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { @@ -37,7 +38,7 @@ const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector }) => { } > <div data-test-subj={'connector-fields'}> - <FieldsComponent connector={connector} key={connector.id} /> + <FieldsComponent path={path} connector={connector} key={connector.id} /> </div> </Suspense> ) : null} diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx index 57772c0b177b7f..9962a291b06c8f 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -13,7 +13,6 @@ import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hoo import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { isEmpty } from 'lodash'; -import type { JiraFieldsType } from '../../../../common/types/domain'; import * as i18n from './translations'; import { useKibana } from '../../../common/lib/kibana'; import type { ConnectorFieldsProps } from '../types'; @@ -23,11 +22,16 @@ import { SearchIssues } from './search_issues'; const { emptyField } = fieldValidators; -const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector }) => { - const [{ fields }] = useFormData<{ fields: JiraFieldsType }>(); +const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ + connector, + path = 'fields', +}) => { + const [formData] = useFormData(); + const { http } = useKibana().services; - const { issueType } = fields ?? {}; + const fieldsData = path === 'caseFields.fields' ? formData?.caseFields?.fields : formData?.fields; + const { issueType } = fieldsData ?? {}; const { isLoading: isLoadingIssueTypesData, @@ -76,7 +80,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co return ( <div data-test-subj={'connector-fields-jira'}> <UseField - path="fields.issueType" + path={`${path}.issueType`} component={SelectField} config={{ label: i18n.ISSUE_TYPE, @@ -107,7 +111,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co <div style={{ display: hasParent ? 'block' : 'none' }}> <EuiFlexGroup> <EuiFlexItem> - <SearchIssues actionConnector={connector} /> + <SearchIssues path={path} actionConnector={connector} /> </EuiFlexItem> </EuiFlexGroup> <EuiSpacer size="m" /> @@ -116,7 +120,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.priority" + path={`${path}.priority`} component={SelectField} config={{ label: i18n.PRIORITY, diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 27df975ac58646..531d9fd36e0491 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -20,9 +20,10 @@ import * as i18n from './translations'; interface Props { actionConnector?: ActionConnector; + path?: string; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, path }) => { const [query, setQuery] = useState<string | null>(null); const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( [] @@ -40,7 +41,7 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); return ( - <UseField path="fields.parent"> + <UseField path={`${path}.parent`}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx index e8260a69a33014..a39a8eaf00aa22 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -21,7 +21,10 @@ import { useGetSeverity } from './use_get_severity'; import * as i18n from './translations'; -const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector }) => { +const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ + connector, + path = 'fields', +}) => { const { http } = useKibana().services; const { @@ -69,7 +72,7 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = return ( <span data-test-subj={'connector-fields-resilient'}> - <UseField<string[]> path="fields.incidentTypes" config={{ defaultValue: [] }}> + <UseField<string[]> path={`${path}.incidentTypes`} config={{ defaultValue: [] }}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -109,7 +112,7 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = </UseField> <EuiSpacer size="m" /> <UseField - path="fields.severityCode" + path={`${path}.severityCode`} component={SelectField} config={{ label: i18n.SEVERITY_LABEL, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 7d6981fda05e42..187716bcba84b5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -13,7 +13,6 @@ import { useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import type { ServiceNowITSMFieldsType } from '../../../../common/types/domain'; import * as i18n from './translations'; import type { ConnectorFieldsProps } from '../types'; @@ -34,11 +33,13 @@ const defaultFields: Fields = { const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector, + path = 'fields', }) => { const form = useFormContext(); - const [{ fields }] = useFormData<{ fields: ServiceNowITSMFieldsType }>(); + const [formData] = useFormData(); + const fieldsData = path === 'caseFields.fields' ? formData?.caseFields?.fields : formData?.fields; - const { category = null } = fields ?? {}; + const { category = null } = fieldsData ?? {}; const { http } = useKibana().services; const showConnectorWarning = connector.isDeprecated; @@ -106,7 +107,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.urgency" + path={`${path}.urgency`} component={SelectField} config={{ label: i18n.URGENCY, @@ -127,7 +128,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.severity" + path={`${path}.severity`} component={SelectField} config={{ label: i18n.SEVERITY, @@ -146,7 +147,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp </EuiFlexItem> <EuiFlexItem> <UseField - path="fields.impact" + path={`${path}.impact`} component={SelectField} config={{ label: i18n.IMPACT, @@ -167,7 +168,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.category" + path={`${path}.category`} component={SelectField} config={{ label: i18n.CATEGORY, @@ -187,7 +188,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp </EuiFlexItem> <EuiFlexItem> <UseField - path="fields.subcategory" + path={`${path}.subcategory`} component={SelectField} config={{ label: i18n.SUBCATEGORY, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index e07fcc204c9da6..0b682909d046eb 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -33,6 +33,7 @@ const defaultFields: Fields = { const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector, + path = 'fields', }) => { const form = useFormContext(); const [{ fields }] = useFormData<{ fields: ServiceNowSIRFieldsType }>(); @@ -100,7 +101,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.destIp" + path={`${path}.destIp`} config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -114,7 +115,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path="fields.sourceIp" + path={`${path}.sourceIp`} config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -130,7 +131,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.malwareUrl" + path={`${path}.malwareUrl`} config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -144,7 +145,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path="fields.malwareHash" + path={`${path}.malwareHash`} config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -164,7 +165,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.priority" + path={`${path}.priority`} component={SelectField} config={{ label: i18n.PRIORITY, @@ -185,7 +186,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path="fields.category" + path={`${path}.category`} component={SelectField} config={{ label: i18n.CATEGORY, @@ -205,7 +206,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path="fields.subcategory" + path={`${path}.subcategory`} component={SelectField} config={{ label: i18n.SUBCATEGORY, diff --git a/x-pack/plugins/cases/public/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts index a4870fd0748f07..e9c2edfb154dcd 100644 --- a/x-pack/plugins/cases/public/components/connectors/types.ts +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -37,6 +37,7 @@ export interface CaseConnectorsRegistry { export interface ConnectorFieldsProps { connector: CaseActionConnector; + path?: 'caseFields.fields' | 'fields'; } export interface ConnectorFieldsPreviewProps<TFields> { diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 4660361b60bb71..8b4fd877de7e3e 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -8,7 +8,7 @@ import React, { memo, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; @@ -24,15 +24,9 @@ interface Props { connectors: ActionConnector[]; isLoading: boolean; isLoadingConnectors: boolean; - path?: string; } -const ConnectorComponent: React.FC<Props> = ({ - connectors, - isLoading, - isLoadingConnectors, - path, -}) => { +const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; @@ -63,13 +57,11 @@ const ConnectorComponent: React.FC<Props> = ({ ); } - console.log('connector component', { defaultConnectorId, path, connectors, connector }); - return ( <EuiFlexGroup> <EuiFlexItem> <UseField - path={path ?? 'connectorId'} + path={'connectorId'} config={connectorIdConfig} component={ConnectorSelector} defaultValue={defaultConnectorId} diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 4dd776b887d8f9..4c95b6e11a11a3 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -48,7 +48,6 @@ import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; import { Category } from './category'; import { CustomFields } from './custom_fields'; -import { schema } from './schema'; const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => big diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx index 0be2c4ea43bcb9..8d11589d851af1 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx @@ -23,6 +23,7 @@ import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; +import { isEmpty } from 'lodash'; export interface CustomFieldFlyoutProps { disabled: boolean; @@ -45,7 +46,7 @@ const CustomFieldFlyoutComponent: React.FC<CustomFieldFlyoutProps> = ({ isValid: undefined, submit: async () => ({ isValid: false, - data: { key: '', label: '', type: CustomFieldTypes.TEXT, required: false }, + data: {}, }), }); diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 8486822e0d0c49..3368f71f068a62 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -8,24 +8,22 @@ import React, { memo, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { TemplateFormProps } from '../templates/types'; -import { CasesConfigurationUI } from '../../containers/types'; +import type { CasesConfigurationUI } from '../../containers/types'; +import { schema } from './schema'; interface Props { connectors: ActionConnector[]; isLoading: boolean; path?: string; - schema: FormSchema<TemplateFormProps>; configurationConnector: CasesConfigurationUI['connector']; } @@ -33,11 +31,10 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, path, - schema, configurationConnector, }) => { - const [{ connectorId }] = useFormData({ watch: ['caseFields.connectorId'] }); - const connector = getConnectorById(connectorId, connectors) ?? null; + const [{ caseFields }] = useFormData({ watch: ['caseFields.connectorId'] }); + const connector = getConnectorById(caseFields?.connectorId, connectors) ?? null; const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); @@ -62,14 +59,6 @@ const ConnectorComponent: React.FC<Props> = ({ ); } - console.log('connector component', { - defaultConnectorId, - path, - connectors, - connector, - connectorId, - }); - return ( <EuiFlexGroup> <EuiFlexItem> @@ -83,13 +72,12 @@ const ConnectorComponent: React.FC<Props> = ({ dataTestSubj: 'caseConnectors', disabled: isLoading, idAria: 'caseConnectors', - isLoading: isLoading, - // handleChange: onConnectorChange, + isLoading, }} /> </EuiFlexItem> <EuiFlexItem> - <ConnectorFieldsForm connector={connector} /> + <ConnectorFieldsForm path={'caseFields.fields'} connector={connector} /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index b91b45781261ca..c1cef0ce017582 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -12,9 +12,9 @@ import { v4 as uuidv4 } from 'uuid'; import type { ActionConnector } from '../../../common/types/domain'; import { schema } from './schema'; import { FormFields } from './form_fields'; -import { templateDeserializer, templateSerializer } from './utils'; +import { templateSerializer } from './utils'; import type { TemplateFormProps } from './types'; -import { CasesConfigurationUI } from '../../containers/types'; +import type { CasesConfigurationUI } from '../../containers/types'; export interface TemplateFormState { isValid: boolean | undefined; @@ -46,7 +46,6 @@ const FormComponent: React.FC<Props> = ({ options: { stripEmptyFields: false }, schema, serializer: templateSerializer, - deserializer: templateDeserializer, }); const { submit, isValid, isSubmitting } = form; diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 2f1294f60f43b0..46ece1412abbc9 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -11,10 +11,9 @@ import { TextField, HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/co import { EuiSteps } from '@elastic/eui'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; -import { schema } from './schema'; import { Connector } from './connector'; -import { ActionConnector } from '../../containers/configure/types'; -import { CasesConfigurationUI } from '../../containers/types'; +import type { ActionConnector } from '../../containers/configure/types'; +import type { CasesConfigurationUI } from '../../containers/types'; interface FormFieldsProps { isSubmitting?: boolean; @@ -82,12 +81,11 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isLoading={isSubmitting} configurationConnector={configurationConnector} path="caseFields.connectorId" - schema={schema} /> </div> ), }), - [connectors, isSubmitting] + [connectors, configurationConnector, isSubmitting] ); const allSteps = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 937f55a6d26f84..681313c225f17c 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -6,7 +6,7 @@ */ import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import { FIELD_TYPES, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { MAX_DESCRIPTION_LENGTH, @@ -128,7 +128,6 @@ export const schema: FormSchema<TemplateFormProps> = { labelAppend: OptionalFieldLabel, }, connectorId: { - type: FIELD_TYPES.SUPER_SELECT, labelAppend: OptionalFieldLabel, label: i18n.CONNECTORS, defaultValue: 'none', diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 47cfa3fbbddf9d..3c4b1cd83733c2 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { ConnectorTypeFields } from '@kbn/cases-plugin/common/types/domain'; -import { getConnectorsFormDeserializer, isEmptyValue } from '../utils'; +import { getConnectorsFormSerializer, isEmptyValue } from '../utils'; import type { TemplateFormProps } from './types'; export const removeEmptyFields = ( @@ -24,8 +23,6 @@ export const removeEmptyFields = ( customFields: nonEmptyFields, }; } - } else if (key === 'connectorFields' && !isEmptyValue(value)) { - initialValue = { [key]: value }; } else if (!isEmptyValue(value)) { initialValue = { [key]: value }; } @@ -42,29 +39,16 @@ export const removeEmptyFields = ( export const templateSerializer = <T extends TemplateFormProps | null>(data: T): T => { if (data !== null && data.caseFields) { - console.log('templateSerializer', { data }); - const serializedFields = removeEmptyFields(data.caseFields); - - return { - ...data, - caseFields: serializedFields as TemplateFormProps['caseFields'], - }; - } - - return data; -}; + const { fields, ...rest } = data.caseFields; + const connectorFields = getConnectorsFormSerializer({ fields: fields ?? null }); + const serializedFields = removeEmptyFields(rest); -export const templateDeserializer = <T extends TemplateFormProps | null>(data: T): T => { - if (data && data.caseFields) { - const connectorFields = data.caseFields.fields - ? getConnectorsFormDeserializer({ fields: data.caseFields.fields }) - : { fields: {} }; return { ...data, caseFields: { - ...data?.caseFields, - fields: connectorFields?.fields, - }, + ...serializedFields, + fields: connectorFields.fields, + } as TemplateFormProps['caseFields'], }; } From 6641ec81baaa74239bf4c7239522817233e9e98f Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 17 May 2024 13:07:45 +0200 Subject: [PATCH 04/28] syncAlerts added --- .../public/components/case_form_fields/index.tsx | 2 +- .../public/components/configure_cases/index.tsx | 2 ++ .../components/create/sync_alerts_toggle.tsx | 16 ++++++++++------ .../cases/public/components/templates/schema.tsx | 1 - 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index 7b95f37752962f..ba2768597d6f86 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -56,7 +56,7 @@ const CaseFormFieldsComponent: React.FC = () => { </div> {isSyncAlertsEnabled ? ( <div> - <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.settings" /> + <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.syncAlerts" /> </div> ) : null} <div css={containerCss(euiTheme)}> 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 db2dea0d7a172f..291896b4525e10 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -351,6 +351,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connectorId, fields, customFields: templateCustomFields, + syncAlerts = false, ...otherCaseFields } = caseFields ?? {}; const transformedCustomFields = templateCustomFields @@ -368,6 +369,7 @@ export const ConfigureCases: React.FC = React.memo(() => { ...otherCaseFields, connector: transformedConnector, customFields: transformedCustomFields, + settings: { syncAlerts }, }, }; const updatedTemplates = addOrReplaceField(templates, transformedData); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index 29782f391168c9..d7d756249605b6 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -6,22 +6,26 @@ */ import React, { memo } from 'react'; -import { getUseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import * as i18n from './translations'; -const CommonUseField = getUseField({ component: Field }); - interface Props { isLoading: boolean; path?: string; } const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading, path }) => { - const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + const [formData] = useFormData(); + + const syncAlerts = + path !== '' ? Boolean(formData?.caseFields?.syncAlerts) : Boolean(formData?.syncAlerts); + return ( - <CommonUseField + <UseField path={path ?? 'syncAlerts'} + component={ToggleField} + config={{ defaultValue: true }} componentProps={{ idAria: 'caseSyncAlerts', 'data-test-subj': 'caseSyncAlerts', diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 681313c225f17c..3c6069dd6e4af4 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -137,7 +137,6 @@ export const schema: FormSchema<TemplateFormProps> = { }, syncAlerts: { helpText: i18n.SYNC_ALERTS_HELP, - defaultValue: true, labelAppend: OptionalFieldLabel, }, }, From 41c025df8c51071366be3b519a263f36a2fef61c Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 21 May 2024 15:36:36 +0200 Subject: [PATCH 05/28] add custom fields component --- .../case_form_fields/custom_fields.tsx | 76 ++++++++++ .../case_form_fields/index.test.tsx | 102 ++++++++++++++ .../components/case_form_fields/index.tsx | 76 +++++----- .../case_form_fields/translations.ts | 14 ++ .../category/category_form_field.tsx | 2 +- .../components/configure_cases/flyout.tsx | 11 +- .../components/configure_cases/index.test.tsx | 24 +++- .../components/configure_cases/index.tsx | 1 + .../components/create/custom_fields.tsx | 6 +- .../components/custom_fields/flyout.tsx | 2 - .../public/components/templates/connector.tsx | 12 +- .../public/components/templates/form.tsx | 6 +- .../components/templates/form_fields.test.tsx | 130 ++++++++++++++++++ .../components/templates/form_fields.tsx | 24 +++- .../components/templates/templates_list.tsx | 34 +---- .../components/templates/translations.ts | 4 - .../use_persist_configuration.test.tsx | 59 +++++++- .../apps/cases/group1/create_case_form.ts | 2 +- 18 files changed, 475 insertions(+), 110 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/translations.ts create mode 100644 x-pack/plugins/cases/public/components/templates/form_fields.test.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx new file mode 100644 index 00000000000000..d66c125ada42b0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { CasesConfigurationUI } from '../../../common/ui'; +import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import * as i18n from './translations'; + +interface Props { + isLoading: boolean; + path?: string; + setAsOptional?: boolean; + configurationCustomFields: CasesConfigurationUI['customFields']; +} + +const CustomFieldsComponent: React.FC<Props> = ({ + isLoading, + path, + setAsOptional, + configurationCustomFields, +}) => { + const sortedCustomFields = useMemo( + () => sortCustomFieldsByLabel(configurationCustomFields), + [configurationCustomFields] + ); + + const customFieldsComponents = sortedCustomFields.map( + (customField: CasesConfigurationUI['customFields'][number]) => { + const customFieldFactory = customFieldsBuilderMap[customField.type]; + const customFieldType = customFieldFactory().build(); + + const CreateComponent = customFieldType.Create; + + return ( + <CreateComponent + isLoading={isLoading} + customFieldConfiguration={customField} + key={customField.key} + path={path} + setAsOptional={setAsOptional} + /> + ); + } + ); + + if (!configurationCustomFields.length) { + return null; + } + + return ( + <EuiFlexGroup direction="column" gutterSize="s"> + <EuiText size="m"> + <h3>{i18n.ADDITIONAL_FIELDS}</h3> + </EuiText> + <EuiSpacer size="xs" /> + <EuiFlexItem data-test-subj="caseCustomFields">{customFieldsComponents}</EuiFlexItem> + </EuiFlexGroup> + ); +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { + return sortBy(configCustomFields, (configCustomField) => { + return configCustomField.label; + }); +}; 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 new file mode 100644 index 00000000000000..e67047c2168893 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { CaseFormFields } from '.'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +describe('CaseFormFields', () => { + let appMock: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultProps = { + configurationCustomFields: [], + draftStorageKey: '', + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + }); + + it('renders case fields correctly', async () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('does not render customFields when empty', () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('caseCustomFields')).not.toBeInTheDocument(); + }); + + it('renders customFields when not empty', async () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields + configurationCustomFields={customFieldsConfigurationMock} + draftStorageKey="" + /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('does not render assignees when no platinum license', () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); + }); + + it('renders assignees when platinum license', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index ba2768597d6f86..2109c011a325ca 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -7,9 +7,7 @@ import React, { memo } from 'react'; import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { EuiThemeComputed } from '@elastic/eui'; -import { logicalCSS, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; +import { EuiFlexGroup } from '@elastic/eui'; import { Title } from '../create/title'; import { Tags } from '../create/tags'; import { Category } from '../create/category'; @@ -17,57 +15,51 @@ import { Severity } from '../create/severity'; import { Description } from '../create/description'; import { useCasesFeatures } from '../../common/use_cases_features'; import { Assignees } from '../create/assignees'; -import { CustomFields } from '../create/custom_fields'; +import { CustomFields } from './custom_fields'; import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; +import type { CasesConfigurationUI } from '../../containers/types'; -const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => - big - ? css` - ${logicalCSS('margin-top', euiTheme.size.xl)}; - ` - : css` - ${logicalCSS('margin-top', euiTheme.size.base)}; - `; +interface Props { + configurationCustomFields: CasesConfigurationUI['customFields']; + draftStorageKey: string; +} -const CaseFormFieldsComponent: React.FC = () => { +const CaseFormFieldsComponent: React.FC<Props> = ({ + configurationCustomFields, + draftStorageKey, +}) => { const { isSubmitting } = useFormContext(); const { caseAssignmentAuthorized, isSyncAlertsEnabled } = useCasesFeatures(); - const { euiTheme } = useEuiTheme(); return ( - <> + <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> <Title isLoading={isSubmitting} path="caseFields.title" /> {caseAssignmentAuthorized ? ( - <div css={containerCss(euiTheme)}> - <Assignees isLoading={isSubmitting} path="caseFields.assignees" /> - </div> + <Assignees isLoading={isSubmitting} path="caseFields.assignees" /> ) : null} - <div css={containerCss(euiTheme)}> - <Tags isLoading={isSubmitting} path="caseFields.tags" /> - </div> - <div css={containerCss(euiTheme)}> - <Category isLoading={isSubmitting} path="caseFields.category" /> - </div> - <div css={containerCss(euiTheme)}> - <Severity isLoading={isSubmitting} path="caseFields.severity" /> - </div> - <div css={containerCss(euiTheme, true)}> - <Description isLoading={isSubmitting} draftStorageKey={''} path="caseFields.description" /> - </div> + <Tags isLoading={isSubmitting} path="caseFields.tags" /> + + <Category isLoading={isSubmitting} path="caseFields.category" /> + + <Severity isLoading={isSubmitting} path="caseFields.severity" /> + + <Description + isLoading={isSubmitting} + path="caseFields.description" + draftStorageKey={draftStorageKey} + /> + {isSyncAlertsEnabled ? ( - <div> - <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.syncAlerts" /> - </div> + <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.syncAlerts" /> ) : null} - <div css={containerCss(euiTheme)}> - <CustomFields - isLoading={isSubmitting} - path="caseFields.customFields" - setAsOptional={true} - /> - </div> - <div /> - </> + + <CustomFields + isLoading={isSubmitting} + path="caseFields.customFields" + setAsOptional={true} + configurationCustomFields={configurationCustomFields} + /> + </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/translations.ts b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts new file mode 100644 index 00000000000000..b8359958025b32 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const ADDITIONAL_FIELDS = i18n.translate('xpack.cases.additionalFields', { + defaultMessage: 'Additional fields', +}); diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index 2f44948bbf2241..f74946c4a0c68c 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -81,7 +81,7 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ label={CATEGORY} error={errorMessage} isInvalid={isInvalid} - data-test-subj="case-create-form-category" + data-test-subj="caseCategory" fullWidth > <CategoryComponent diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 80009e79dd522b..de050e08b20636 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -38,8 +38,9 @@ export interface FlyoutProps { onSaveField: (data: CustomFieldConfiguration | TemplateFormProps | null) => void; data: CustomFieldConfiguration | TemplateConfiguration | null; type: 'customField' | 'template'; - connectors: ActionConnector[]; - configurationConnector: CasesConfigurationUI['connector']; + connectors?: ActionConnector[]; + configurationConnector?: CasesConfigurationUI['connector']; + configurationCustomFields?: CasesConfigurationUI['customFields']; } const FlyoutComponent: React.FC<FlyoutProps> = ({ @@ -51,6 +52,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ type, connectors, configurationConnector, + configurationCustomFields, }) => { const dataTestSubj = `${type}Flyout`; @@ -92,8 +94,9 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ <TemplateForm onChange={setFormState} initialValue={initialValue as TemplateFormProps} - connectors={connectors} - configurationConnector={configurationConnector} + connectors={connectors ?? []} + configurationConnector={configurationConnector ?? null} + configurationCustomFields={configurationCustomFields ?? []} /> ) : null} </EuiFlyoutBody> 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 2a62ee87badb31..bf9545724bd581 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 @@ -425,6 +425,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-user', customFields: [], + templates: [], id: '', version: '', }); @@ -521,6 +522,7 @@ describe('ConfigureCases', () => { }, closureType: 'close-by-pushing', customFields: [], + templates: [], id: '', version: '', }); @@ -706,6 +708,7 @@ describe('ConfigureCases', () => { { ...customFieldsConfigurationMock[2] }, { ...customFieldsConfigurationMock[3] }, ], + templates: [], id: '', version: '', }); @@ -824,10 +827,29 @@ describe('ConfigureCases', () => { }); }); - describe('rendering with license limitations', () => { + describe('templates', () => { let appMockRender: AppMockRenderer; let persistCaseConfigure: jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + persistCaseConfigure = jest.fn(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + }); + + it('should render template section', async () => { + appMockRender.render(<ConfigureCases />); + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + }); + }); + + describe('rendering with license limitations', () => { + let appMockRender: AppMockRenderer; + let persistCaseConfigure: jest.Mock; beforeEach(() => { // Default setup jest.clearAllMocks(); 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 291896b4525e10..db43eb951f20e4 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -414,6 +414,7 @@ export const ConfigureCases: React.FC = React.memo(() => { data={flyoutType === 'template' ? templateToEdit : customFieldToEdit} connectors={connectors ?? []} configurationConnector={connector} + configurationCustomFields={customFields} onCloseFlyout={onCloseAddFieldFlyout} onSaveField={onFlyoutSave} /> diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx index d87d3cbfdc10a4..28cebde65db27e 100644 --- a/x-pack/plugins/cases/public/components/create/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/create/custom_fields.tsx @@ -19,11 +19,9 @@ import { getConfigurationByOwner } from '../../containers/configure/utils'; interface Props { isLoading: boolean; - path?: string; - setAsOptional?: boolean; } -const CustomFieldsComponent: React.FC<Props> = ({ isLoading, path, setAsOptional }) => { +const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { const { owner } = useCasesContext(); const [{ selectedOwner }] = useFormData<{ selectedOwner: string }>({ watch: ['selectedOwner'] }); const { data: configurations, isLoading: isLoadingCaseConfiguration } = @@ -56,8 +54,6 @@ const CustomFieldsComponent: React.FC<Props> = ({ isLoading, path, setAsOptional isLoading={isLoading || isLoadingCaseConfiguration} customFieldConfiguration={customField} key={customField.key} - path={path} - setAsOptional={setAsOptional} /> ); } diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx index 8d11589d851af1..e8fb5a937da9ad 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx @@ -20,10 +20,8 @@ import { import type { CustomFieldFormState } from './form'; import { CustomFieldsForm } from './form'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; -import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; -import { isEmpty } from 'lodash'; export interface CustomFieldFlyoutProps { disabled: boolean; diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 3368f71f068a62..982d6c35bd3d52 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; @@ -24,7 +24,7 @@ interface Props { connectors: ActionConnector[]; isLoading: boolean; path?: string; - configurationConnector: CasesConfigurationUI['connector']; + configurationConnector: CasesConfigurationUI['connector'] | null; } const ConnectorComponent: React.FC<Props> = ({ @@ -40,12 +40,6 @@ const ConnectorComponent: React.FC<Props> = ({ const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; - const defaultConnectorId = useMemo(() => { - return connectors.some((c) => c.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [configurationConnector.id, connectors]); - const connectorIdConfig = getConnectorsFormValidators({ config: schema.caseFields?.connectorId as FieldConfig, connectors, @@ -66,7 +60,7 @@ const ConnectorComponent: React.FC<Props> = ({ path={path ?? 'connectorId'} config={connectorIdConfig} component={ConnectorSelector} - defaultValue={defaultConnectorId} + defaultValue={configurationConnector !== null ? configurationConnector.id : ''} componentProps={{ connectors, dataTestSubj: 'caseConnectors', diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index c1cef0ce017582..7173ba0c6afd64 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -25,7 +25,8 @@ interface Props { onChange: (state: TemplateFormState) => void; initialValue: TemplateFormProps | null; connectors: ActionConnector[]; - configurationConnector: CasesConfigurationUI['connector']; + configurationConnector: CasesConfigurationUI['connector'] | null; + configurationCustomFields: CasesConfigurationUI['customFields']; } const FormComponent: React.FC<Props> = ({ @@ -33,6 +34,7 @@ const FormComponent: React.FC<Props> = ({ initialValue, connectors, configurationConnector, + configurationCustomFields, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -60,9 +62,9 @@ const FormComponent: React.FC<Props> = ({ <Form form={form}> <FormFields isSubmitting={isSubmitting} - isEditMode={Boolean(initialValue)} connectors={connectors} configurationConnector={configurationConnector} + configurationCustomFields={configurationCustomFields} /> </Form> ); 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 new file mode 100644 index 00000000000000..d83c3cdc097206 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormFields } from './form_fields'; +import { FormTestComponent } from '../../common/test_utils'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { ConnectorTypes } from '../../../common/types/domain'; +import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS } from './translations'; + +describe('form fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultProps = { + connectors: connectorsMock, + configurationConnector: { + id: 'none', + type: ConnectorTypes.none, + fields: null, + name: 'My Connector', + }, + configurationCustomFields: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all steps', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByText(TEMPLATE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CONNECTOR_FIELDS)).toBeInTheDocument(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('renders case fields', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + }); + + it('renders default connector correctly', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders connector and its fields correctly', async () => { + const newProps = { + ...defaultProps, + configurationConnector: { + id: 'servicenow-1', + name: 'my_service_now_connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }; + + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 46ece1412abbc9..79695fde79ae7d 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -14,20 +14,29 @@ import * as i18n from './translations'; import { Connector } from './connector'; import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; interface FormFieldsProps { isSubmitting?: boolean; - isEditMode?: boolean; connectors: ActionConnector[]; - configurationConnector: CasesConfigurationUI['connector']; + configurationConnector: CasesConfigurationUI['connector'] | null; + configurationCustomFields: CasesConfigurationUI['customFields']; } const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting = false, - isEditMode, connectors, configurationConnector, + configurationCustomFields, }) => { + const { owner } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey({ + appId: owner[0], + caseId: 'createCaseTemplate', + commentId: 'description', + }); + const firstStep = useMemo( () => ({ title: i18n.TEMPLATE_FIELDS, @@ -66,9 +75,14 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ const secondStep = useMemo( () => ({ title: i18n.CASE_FIELDS, - children: <CaseFormFields />, + children: ( + <CaseFormFields + configurationCustomFields={configurationCustomFields} + draftStorageKey={draftStorageKey} + /> + ), }), - [] + [configurationCustomFields, draftStorageKey] ); const thirdStep = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx index dbadac1f4aed9f..c18fbfc1795736 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -5,19 +5,16 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import type { CasesConfigurationUITemplate } from '../../../common/ui'; export interface Props { templates: CasesConfigurationUITemplate[]; - // onDeleteCustomField: (key: string) => void; - // onEditCustomField: (key: string) => void; } const TemplatesListComponent: React.FC<Props> = (props) => { const { templates } = props; - const [selectedItem, setSelectedItem] = useState<CasesConfigurationUITemplate | null>(null); return templates.length ? ( <> @@ -41,41 +38,12 @@ const TemplatesListComponent: React.FC<Props> = (props) => { </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> - {/* <EuiFlexItem grow={false}> - <EuiFlexGroup alignItems="flexEnd" gutterSize="s"> - <EuiFlexItem grow={false}> - <EuiButtonIcon - data-test-subj={`${customField.key}-custom-field-edit`} - aria-label={`${customField.key}-custom-field-edit`} - iconType="pencil" - color="primary" - onClick={() => onEditCustomField(customField.key)} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonIcon - data-test-subj={`${customField.key}-custom-field-delete`} - aria-label={`${customField.key}-custom-field-delete`} - iconType="minusInCircle" - color="danger" - onClick={() => setSelectedItem(customField)} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> */} </EuiFlexGroup> </EuiPanel> <EuiSpacer size="s" /> </React.Fragment> ))} </EuiFlexItem> - {/* {showModal && selectedItem ? ( - <DeleteConfirmationModal - label={selectedItem.label} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ) : null} */} </EuiFlexGroup> </> ) : null; diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index 675f8f9fe72f08..e127f248fd1ba4 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -54,7 +54,3 @@ export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { defaultMessage: 'External Connector Fields', }); - -// export const SAVE = i18n.translate('xpack.cases.templates.saveTemplate', { -// defaultMessage: 'Save', -// }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx index 47816321d684ae..c1839bfbac6819 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -14,13 +14,14 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { ConnectorTypes } from '../../../common'; import { casesQueriesKeys } from '../constants'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); const useToastMock = useToasts as jest.Mock; -describe('useCreateAttachments', () => { +describe('usePersistConfiguration', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -98,6 +99,34 @@ describe('useCreateAttachments', () => { }); }); + it('calls postCaseConfigure with correct data', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id' }); + }); + + await waitForNextUpdate(); + + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + owner: 'securitySolution', + }); + }); + it('calls patchCaseConfigure when the id and the version are not empty', async () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); @@ -122,6 +151,34 @@ describe('useCreateAttachments', () => { }); }); + it('calls patchCaseConfigure with correct data', async () => { + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const newRequest = { + ...request, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); + }); + + await waitForNextUpdate(); + + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + version: 'test-version', + }); + }); + it('invalidates the queries correctly', async () => { const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index fcb1e23d6f9bb6..cbd2537179040c 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -93,7 +93,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { 'The length of the tag is too long. The maximum length is 256 characters.' ); - const category = await testSubjects.find('case-create-form-category'); + const category = await testSubjects.find('caseCategory'); expect(await category.getVisibleText()).contain( 'The length of the category is too long. The maximum length is 50 characters.' ); From 5de5bd24a9d0c2839b59db1cc771fe34cfab636f Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 23 May 2024 11:29:27 +0200 Subject: [PATCH 06/28] add tags field, update default value of connector --- .../components/configure_cases/flyout.tsx | 6 +- .../components/configure_cases/index.tsx | 2 +- .../cases/public/components/create/tags.tsx | 7 +- .../public/components/templates/connector.tsx | 10 +-- .../public/components/templates/form.tsx | 6 +- .../components/templates/form_fields.tsx | 10 ++- .../public/components/templates/schema.tsx | 79 ++++++++++++------- .../components/templates/translations.ts | 5 ++ .../public/components/templates/utils.ts | 56 +++++++++++++ 9 files changed, 132 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index de050e08b20636..bece367e77ab4e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -39,7 +39,7 @@ export interface FlyoutProps { data: CustomFieldConfiguration | TemplateConfiguration | null; type: 'customField' | 'template'; connectors?: ActionConnector[]; - configurationConnector?: CasesConfigurationUI['connector']; + configurationConnectorId?: string; configurationCustomFields?: CasesConfigurationUI['customFields']; } @@ -51,7 +51,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ data: initialValue, type, connectors, - configurationConnector, + configurationConnectorId, configurationCustomFields, }) => { const dataTestSubj = `${type}Flyout`; @@ -95,7 +95,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ onChange={setFormState} initialValue={initialValue as TemplateFormProps} connectors={connectors ?? []} - configurationConnector={configurationConnector ?? null} + configurationConnectorId={configurationConnectorId ?? ''} configurationCustomFields={configurationCustomFields ?? []} /> ) : null} 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 db43eb951f20e4..4496adb18fb77a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -413,7 +413,7 @@ export const ConfigureCases: React.FC = React.memo(() => { type={flyoutType} data={flyoutType === 'template' ? templateToEdit : customFieldToEdit} connectors={connectors ?? []} - configurationConnector={connector} + configurationConnectorId={connector.id} configurationCustomFields={customFields} onCloseFlyout={onCloseAddFieldFlyout} onSaveField={onFlyoutSave} diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index 9744e198fbac3f..211a7d3219b7b1 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -14,9 +14,10 @@ import * as i18n from './translations'; interface Props { isLoading: boolean; path?: string; + dataTestSubject?: string; } -const TagsComponent: React.FC<Props> = ({ isLoading, path }) => { +const TagsComponent: React.FC<Props> = ({ isLoading, path, dataTestSubject }) => { const { data: tagOptions = [], isLoading: isLoadingTags } = useGetTags(); const options = useMemo( () => @@ -32,8 +33,8 @@ const TagsComponent: React.FC<Props> = ({ isLoading, path }) => { component={ComboBoxField} defaultValue={[]} componentProps={{ - idAria: 'caseTags', - 'data-test-subj': 'caseTags', + idAria: dataTestSubject ?? 'caseTags', + 'data-test-subj': dataTestSubject ?? 'caseTags', euiFieldProps: { fullWidth: true, placeholder: '', diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 982d6c35bd3d52..7a7972a34d94a5 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -17,21 +17,20 @@ import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; import { useCasesContext } from '../cases_context/use_cases_context'; -import type { CasesConfigurationUI } from '../../containers/types'; import { schema } from './schema'; interface Props { connectors: ActionConnector[]; isLoading: boolean; path?: string; - configurationConnector: CasesConfigurationUI['connector'] | null; + configurationConnectorId: string; } const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, path, - configurationConnector, + configurationConnectorId, }) => { const [{ caseFields }] = useFormData({ watch: ['caseFields.connectorId'] }); const connector = getConnectorById(caseFields?.connectorId, connectors) ?? null; @@ -39,9 +38,10 @@ const ConnectorComponent: React.FC<Props> = ({ const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; + const connectorId = schema.caseFields?.connectorId ?? ''; const connectorIdConfig = getConnectorsFormValidators({ - config: schema.caseFields?.connectorId as FieldConfig, + config: connectorId as FieldConfig, connectors, }); @@ -60,7 +60,7 @@ const ConnectorComponent: React.FC<Props> = ({ path={path ?? 'connectorId'} config={connectorIdConfig} component={ConnectorSelector} - defaultValue={configurationConnector !== null ? configurationConnector.id : ''} + defaultValue={configurationConnectorId} componentProps={{ connectors, dataTestSubj: 'caseConnectors', diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 7173ba0c6afd64..9bca4ae3b82c05 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -25,7 +25,7 @@ interface Props { onChange: (state: TemplateFormState) => void; initialValue: TemplateFormProps | null; connectors: ActionConnector[]; - configurationConnector: CasesConfigurationUI['connector'] | null; + configurationConnectorId: string; configurationCustomFields: CasesConfigurationUI['customFields']; } @@ -33,7 +33,7 @@ const FormComponent: React.FC<Props> = ({ onChange, initialValue, connectors, - configurationConnector, + configurationConnectorId, configurationCustomFields, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -63,7 +63,7 @@ const FormComponent: React.FC<Props> = ({ <FormFields isSubmitting={isSubmitting} connectors={connectors} - configurationConnector={configurationConnector} + configurationConnectorId={configurationConnectorId} configurationCustomFields={configurationCustomFields} /> </Form> diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 79695fde79ae7d..29d42ac1da9fb2 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -16,18 +16,19 @@ import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { Tags } from '../create/tags'; interface FormFieldsProps { isSubmitting?: boolean; connectors: ActionConnector[]; - configurationConnector: CasesConfigurationUI['connector'] | null; + configurationConnectorId: string; configurationCustomFields: CasesConfigurationUI['customFields']; } const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting = false, connectors, - configurationConnector, + configurationConnectorId, configurationCustomFields, }) => { const { owner } = useCasesContext(); @@ -54,6 +55,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ }, }} /> + <Tags isLoading={isSubmitting} path="tags" dataTestSubject="template-tags" /> <UseField path="description" component={TextField} @@ -93,13 +95,13 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ <Connector connectors={connectors} isLoading={isSubmitting} - configurationConnector={configurationConnector} + configurationConnectorId={configurationConnectorId} path="caseFields.connectorId" /> </div> ), }), - [connectors, configurationConnector, isSubmitting] + [connectors, configurationConnectorId, isSubmitting] ); const allSteps = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 3c6069dd6e4af4..45eebf48ae8578 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -12,17 +12,17 @@ import { MAX_DESCRIPTION_LENGTH, MAX_LENGTH_PER_TAG, MAX_TAGS_PER_CASE, + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_TAG_LENGTH, MAX_TITLE_LENGTH, } from '../../../common/constants'; import { OptionalFieldLabel } from '../create/optional_field_label'; import { SEVERITY_TITLE } from '../severity/translations'; import * as i18n from './translations'; import type { TemplateFormProps } from './types'; +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; const { emptyField, maxLengthField } = fieldValidators; -const isInvalidTag = (value: string) => value.trim() === ''; - -const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; export const schema: FormSchema<TemplateFormProps> = { key: { @@ -48,6 +48,37 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, + tags: { + label: i18n.TAGS, + helpText: i18n.TEMPLATE_TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_TEMPLATE_TAG_LENGTH), + limit: MAX_TEMPLATE_TAG_LENGTH, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_TEMPLATE), + limit: MAX_TAGS_PER_TEMPLATE, + }), + }, + ], + }, caseFields: { title: { label: i18n.NAME, @@ -79,41 +110,28 @@ export const schema: FormSchema<TemplateFormProps> = { labelAppend: OptionalFieldLabel, validations: [ { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isInvalidTag(value)) || - (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) - ) { - return { - message: i18n.TAGS_EMPTY_ERROR, - }; - } - }, + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), type: VALIDATION_TYPES.ARRAY_ITEM, isBlocking: false, }, { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isTagCharactersInLimit(value)) || - (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) - ) { - return { - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - }; - } - }, + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + limit: MAX_LENGTH_PER_TAG, + }), type: VALIDATION_TYPES.ARRAY_ITEM, isBlocking: false, }, { - validator: ({ value }: { value: string[] }) => { - if (Array.isArray(value) && value.length > MAX_TAGS_PER_CASE) { - return { - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - }; - } - }, + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + limit: MAX_TAGS_PER_CASE, + }), }, ], }, @@ -138,6 +156,7 @@ export const schema: FormSchema<TemplateFormProps> = { syncAlerts: { helpText: i18n.SYNC_ALERTS_HELP, labelAppend: OptionalFieldLabel, + defaultValue: true, }, }, }; diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index e127f248fd1ba4..5fa7f7167ac720 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -43,6 +43,11 @@ export const TEMPLATE_NAME = i18n.translate('xpack.cases.templates.templateName' defaultMessage: 'Template name', }); +export const TEMPLATE_TAGS_HELP = i18n.translate('xpack.cases.templates.templateTagsHelp', { + defaultMessage: + 'Type one or more custom identifying tags for this template. Please enter after each tag to begin a new one', +}); + export const TEMPLATE_FIELDS = i18n.translate('xpack.cases.templates.templateFields', { defaultMessage: 'Template fields', }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 3c4b1cd83733c2..0bd035b75a9b97 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -54,3 +54,59 @@ export const templateSerializer = <T extends TemplateFormProps | null>(data: T): return data; }; + +const isInvalidTag = (value: string) => value.trim() === ''; + +const isTagCharactersInLimit = (value: string, limit: number) => value.trim().length > limit; + +export const validateEmptyTags = ({ + value, + message, +}: { + value: string | string[]; + message: string; +}) => { + if ( + (!Array.isArray(value) && isInvalidTag(value)) || + (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) + ) { + return { + message, + }; + } +}; + +export const validateMaxLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if ( + (!Array.isArray(value) && value.trim().length > limit) || + (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) + ) { + return { + message, + }; + } +}; + +export const validateMaxTagsLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if (Array.isArray(value) && value.length > limit) { + return { + message, + }; + } +}; From 027ad22a28d9e9e32dc63d585fc051c5cbe9e35f Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 23 May 2024 14:07:16 +0200 Subject: [PATCH 07/28] add unit tests --- .../case_form_fields/index.test.tsx | 183 +++++++++++- .../components/configure_cases/index.tsx | 16 +- .../create/sync_alerts_toggle.test.tsx | 86 +++--- .../components/create/sync_alerts_toggle.tsx | 4 +- .../components/templates/connector.test.tsx | 166 +++++++++++ .../public/components/templates/form.test.tsx | 262 ++++++++++++++++++ .../components/templates/form_fields.test.tsx | 209 +++++++++++++- 7 files changed, 863 insertions(+), 63 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/templates/connector.test.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/form.test.tsx 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 e67047c2168893..bebdfe29426a97 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 @@ -6,13 +6,22 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import { CaseFormFields } from '.'; import { FormTestComponent } from '../../common/test_utils'; import { customFieldsConfigurationMock } from '../../containers/mock'; -import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; + +import { CaseFormFields } from '.'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../containers/user_profiles/api'); + +const appId = 'securitySolution'; +const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; describe('CaseFormFields', () => { let appMock: AppMockRenderer; @@ -27,6 +36,10 @@ describe('CaseFormFields', () => { jest.clearAllMocks(); }); + afterEach(() => { + sessionStorage.removeItem(draftKey); + }); + it('renders correctly', async () => { appMock.render( <FormTestComponent onSubmit={onSubmit}> @@ -49,6 +62,7 @@ describe('CaseFormFields', () => { expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); }); it('does not render customFields when empty', () => { @@ -99,4 +113,167 @@ describe('CaseFormFields', () => { expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); + + it('does not render syncAlerts when feature is not enabled', () => { + appMock = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); + + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('caseSyncAlerts')).not.toBeInTheDocument(); + }); + + it('calls onSubmit with case fields', async () => { + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + syncAlerts: true, + }, + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); + + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: null, + tags: [], + + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + }, + }, + true + ); + }); + }); + + describe('Assignees', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('calls onSubmit with assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + userEvent.click(await within(assigneesComboBox).findByTestId('comboBoxToggleListButton')); + + userEvent.paste(await within(assigneesComboBox).findByTestId('comboBoxSearchInput'), 'dr'); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: null, + tags: [], + assignees: [{ uid: userProfiles[0].uid }], + }, + }, + true + ); + }); + }); + }); }); 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 4496adb18fb77a..31852fade7e455 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -285,10 +285,24 @@ export const ConfigureCases: React.FC = React.memo(() => { (key: string) => { const remainingCustomFields = customFields.filter((field) => field.key !== key); + // delete the same custom field from each template as well + const templatesWithRemainingCustomFields = templates.map((template) => { + const templateCustomFields = + template.caseFields?.customFields?.filter((field) => field.key !== key) ?? []; + + return { + ...template, + caseFields: { + ...template.caseFields, + customFields: [...templateCustomFields], + }, + }; + }); + persistCaseConfigure({ connector, customFields: [...remainingCustomFields], - templates, + templates: [...templatesWithRemainingCustomFields], id: configurationId, version: configurationVersion, closureType, diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx index 9ac7658547725b..8859e3a7e3914f 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx @@ -5,78 +5,78 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { FormProps } from './schema'; import { schema } from './schema'; +import { FormTestComponent } from '../../common/test_utils'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; describe('SyncAlertsToggle', () => { - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { syncAlerts: true }, - schema: { - syncAlerts: schema.syncAlerts, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultFormProps = { + onSubmit, + formDefaultValue: { syncAlerts: true }, + schema: { + syncAlerts: schema.syncAlerts, + }, }; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('it renders', async () => { - const wrapper = mount( - <MockHookWrapperComponent> + appMockRender.render( + <FormTestComponent> <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'true'); + expect(await screen.findByText('On')).toBeInTheDocument(); }); it('it toggles the switch', async () => { - const wrapper = mount( - <MockHookWrapperComponent> + appMockRender.render( + <FormTestComponent> <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + const synAlerts = await screen.findByTestId('caseSyncAlerts'); - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ syncAlerts: false }); - }); + userEvent.click(within(synAlerts).getByRole('switch')); + + expect(await screen.findByRole('switch')).toHaveAttribute('aria-checked', 'false'); + expect(await screen.findByText('Off')).toBeInTheDocument(); }); - it('it shows the correct labels', async () => { - const wrapper = mount( - <MockHookWrapperComponent> + it('calls onSubmit with correct data', async () => { + appMockRender.render( + <FormTestComponent {...defaultFormProps}> <SyncAlertsToggle isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text()).toBe( - 'On' - ); + const synAlerts = await screen.findByTestId('caseSyncAlerts'); + + userEvent.click(within(synAlerts).getByRole('switch')); - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + userEvent.click(screen.getByText('Submit')); await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text() - ).toBe('Off'); + expect(onSubmit).toBeCalledWith( + { + syncAlerts: false, + }, + true + ); }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index d7d756249605b6..c5dda58ddf88cb 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -19,7 +19,9 @@ const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading, path }) => { const [formData] = useFormData(); const syncAlerts = - path !== '' ? Boolean(formData?.caseFields?.syncAlerts) : Boolean(formData?.syncAlerts); + path && path === 'caseFields.syncAlerts' + ? Boolean(formData?.caseFields?.syncAlerts) + : Boolean(formData?.syncAlerts); return ( <UseField diff --git a/x-pack/plugins/cases/public/components/templates/connector.test.tsx b/x-pack/plugins/cases/public/components/templates/connector.test.tsx new file mode 100644 index 00000000000000..5aa19480199e47 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/connector.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { connectorsMock } from '../../containers/mock'; +import { Connector } from './connector'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; +import { noConnectorsCasePermission, createAppMockRenderer } from '../../common/mock'; + +import { FormTestComponent } from '../../common/test_utils'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; + +const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes, +}; + +const useGetSeverityResponse = { + isLoading: false, + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, +}; + +const defaultProps = { + connectors: connectorsMock, + isLoading: false, + configurationConnectorId: 'none', + path: 'caseFields.connectorId', +}; + +describe('Connector', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRender.render( + <FormTestComponent> + <Connector {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields')).not.toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render( + <FormTestComponent> + <Connector {...{ ...defaultProps, isLoading: true }} /> + </FormTestComponent> + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + expect(await screen.findByTestId('dropdown-connectors')).toBeDisabled(); + }); + + it('renders default connector correctly', async () => { + appMockRender.render( + <FormTestComponent> + <Connector {...{ ...defaultProps, configurationConnectorId: connectorsMock[2].id }} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByText('Jira')).toBeInTheDocument(); + + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('shows all connectors in dropdown', async () => { + appMockRender.render( + <FormTestComponent> + <Connector {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[0].id}`) + ).toBeInTheDocument(); + expect( + await screen.findByTestId(`dropdown-connector-${connectorsMock[1].id}`) + ).toBeInTheDocument(); + }); + + it(`loads connector fields when dropdown selected`, async () => { + appMockRender.render( + <FormTestComponent> + <Connector {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('dropdown-connectors')); + + await waitForEuiPopoverOpen(); + + userEvent.click(await screen.findByTestId('dropdown-connector-resilient-2')); + + expect(await screen.findByTestId('connector-fields-resilient')).toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + appMockRender.render( + <FormTestComponent> + <Connector {...defaultProps} /> + </FormTestComponent> + ); + expect( + await screen.findByTestId('create-case-connector-permissions-error-msg') + ).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); + + it('shows the actions permission message if the user does not have access to case connector', async () => { + appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); + + appMockRender.render( + <FormTestComponent> + <Connector {...defaultProps} /> + </FormTestComponent> + ); + expect(screen.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); + expect(screen.queryByTestId('caseConnectors')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx new file mode 100644 index 00000000000000..2252a3fc001291 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { TemplateFormState } from './form'; +import { TemplateForm } from './form'; +import { connectorsMock } from '../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +describe('TemplateForm', () => { + let appMockRenderer: AppMockRenderer; + const defaultProps = { + connectors: connectorsMock, + configurationConnectorId: 'none', + configurationCustomFields: [], + onChange: jest.fn(), + initialValue: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + it('renders correctly', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('renders all default fields', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders all fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: null, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Template 1'); + expect(await screen.findByTestId('template-description-input')).toHaveValue( + 'Sample description' + ); + expect(await screen.findByTestId('case-form-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + }); + + it('renders case fields as per initialValue', async () => { + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_key_1', + name: 'Template 1', + description: 'Sample description', + caseFields: { + title: 'Case with template 1', + description: 'case description', + }, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case with template 1' + ); + expect( + await within(await screen.findByTestId('caseDescription')).findByTestId( + 'euiMarkdownEditorTextArea' + ) + ).toHaveValue('case description'); + }); + + it('serializes the template field data correctly', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'bar'); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Template 1', + description: 'this is a first template', + tags: ['foo', 'bar'], + caseFields: { + connectorId: 'none', + fields: null, + syncAlerts: true, + }, + }); + }); + }); + + it('serializes the case field data correctly', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Template 1', + description: 'this is a first template', + tags: [], + caseFields: { + title: 'Case with Template 1', + description: 'This is a case description', + tags: ['template-1'], + category: 'new', + connectorId: 'none', + fields: null, + syncAlerts: true, + }, + }); + }); + }); + + it('serializes the connector fields data correctly', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ ...defaultProps, configurationConnectorId: 'servicenow-1', onChange: onChangeState }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Template 1', + description: 'this is a first template', + tags: [], + caseFields: { + connectorId: 'servicenow-1', + fields: { + category: 'software', + impact: null, + severity: null, + subcategory: null, + urgency: '1', + }, + syncAlerts: true, + }, + }); + }); + }); +}); 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 d83c3cdc097206..e10f01289a3401 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 @@ -6,32 +6,40 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import { FormFields } from './form_fields'; import { FormTestComponent } from '../../common/test_utils'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; -import { ConnectorTypes } from '../../../common/types/domain'; import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS } from './translations'; +import { FormFields } from './form_fields'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; describe('form fields', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); const defaultProps = { connectors: connectorsMock, - configurationConnector: { - id: 'none', - type: ConnectorTypes.none, - fields: null, - name: 'My Connector', - }, + configurationConnectorId: 'none', configurationCustomFields: [], }; + const appId = 'securitySolution'; + const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; beforeEach(() => { jest.clearAllMocks(); appMockRenderer = createAppMockRenderer(); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + }); + + afterEach(() => { + sessionStorage.removeItem(draftKey); }); it('renders correctly', async () => { @@ -64,6 +72,7 @@ describe('form fields', () => { ); expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); }); @@ -109,12 +118,7 @@ describe('form fields', () => { it('renders connector and its fields correctly', async () => { const newProps = { ...defaultProps, - configurationConnector: { - id: 'servicenow-1', - name: 'my_service_now_connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + configurationConnectorId: 'servicenow-1', }; appMockRenderer.render( @@ -127,4 +131,179 @@ describe('form fields', () => { expect(await screen.findByTestId('connector-fields')).toBeInTheDocument(); expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: null, + connectorId: 'none', + tags: [], + syncAlerts: true, + }, + name: 'Template 1', + description: 'this is a first template', + tags: ['first'], + }, + true + ); + }); + }); + + it('calls onSubmit with case fields', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseTags = await screen.findByTestId('caseTags'); + userEvent.paste(within(caseTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + connectorId: 'none', + syncAlerts: true, + }, + tags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: null, + tags: [], + connectorId: 'none', + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + syncAlerts: true, + }, + tags: [], + }, + true + ); + }); + }); + + it('calls onSubmit with connector fields', async () => { + const newProps = { + ...defaultProps, + configurationConnectorId: 'servicenow-1', + }; + + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('severitySelect'), '3'); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '2'); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + tags: [], + category: null, + connectorId: 'servicenow-1', + fields: { + category: 'software', + severity: '3', + urgency: '2', + }, + syncAlerts: true, + }, + tags: [], + }, + true + ); + }); + }); }); From e64508c2872cc3fb9797b02317917e1eaa765b85 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 24 May 2024 14:12:19 +0100 Subject: [PATCH 08/28] Remove custom field flyout, add tests --- .../configure_cases/flyout.test.tsx | 287 ++++++++++++++++++ .../components/configure_cases/flyout.tsx | 8 +- .../components/configure_cases/index.test.tsx | 6 +- .../components/custom_fields/flyout.test.tsx | 270 ---------------- .../components/custom_fields/flyout.tsx | 104 ------- .../components/templates/connector.test.tsx | 1 - .../public/components/templates/form.test.tsx | 167 +++++++++- .../components/templates/index.test.tsx | 95 ++++++ .../public/components/templates/index.tsx | 30 +- .../templates/templates_list.test.tsx | 72 +++++ .../components/templates/templates_list.tsx | 24 +- .../components/templates/translations.ts | 6 + 12 files changed, 670 insertions(+), 400 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/custom_fields/flyout.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/templates_list.test.tsx 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 new file mode 100644 index 00000000000000..3dd90391dd22c1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { CommonFlyout } from './flyout'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { + MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, +} from '../../../common/constants'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +import * as i18n from './translations'; +import { FIELD_LABEL, DEFAULT_VALUE, REQUIRED_FIELD } from '../custom_fields/translations'; + +describe('CommonFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + data: null, + type: 'customField' as const, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders custom field correctly', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + expect(await screen.findByTestId(`${props.type}Flyout`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${props.type}FlyoutHeader`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${props.type}FlyoutCancel`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${props.type}FlyoutSave`)).toBeInTheDocument(); + }); + + it('renders template flyout correctly', async () => { + const newProps = { + ...props, + type: 'template' as const, + }; + appMockRender.render(<CommonFlyout {...newProps} />); + + expect(await screen.findByTestId(`${newProps.type}Flyout`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${newProps.type}FlyoutHeader`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${newProps.type}FlyoutCancel`)).toBeInTheDocument(); + expect(await screen.findByTestId(`${newProps.type}FlyoutSave`)).toBeInTheDocument(); + }); + + describe('CustomFieldsFlyout', () => { + it('shows error if field label is too long', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + userEvent.type(await screen.findByTestId('custom-field-label-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) + ) + ).toBeInTheDocument(); + }); + + it('does not call onSaveField when error', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + expect( + await screen.findByText(REQUIRED_FIELD(FIELD_LABEL.toLocaleLowerCase())) + ).toBeInTheDocument(); + + expect(props.onSaveField).not.toBeCalled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.click(await screen.findByTestId(`${props.type}FlyoutCancel`)); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + describe('Text custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'Default value' + ); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + defaultValue: 'Default value', + }); + }); + }); + + it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TEXT, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + appMockRender.render( + <CommonFlyout {...{ ...props, customField: customFieldsConfigurationMock[0] }} /> + ); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('text-custom-field-required')).toHaveAttribute('checked'); + expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].defaultValue + ); + }); + + it('shows an error if default value is too long', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('text-custom-field-required')); + userEvent.paste( + await screen.findByTestId('text-custom-field-default-value'), + 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) + ); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(DEFAULT_VALUE.toLowerCase(), MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); + + describe('Toggle custom field', () => { + it('calls onSaveField with correct params when a custom field is NOT required', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('calls onSaveField with the correct default value when a custom field is required', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('toggle-custom-field-required')); + userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: CustomFieldTypes.TOGGLE, + defaultValue: false, + }); + }); + }); + + it('renders flyout with the correct data when an initial customField value exists', async () => { + appMockRender.render( + <CommonFlyout {...{ ...props, customField: customFieldsConfigurationMock[1] }} /> + ); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[1].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute( + 'checked' + ); + expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index bece367e77ab4e..75ad297adae320 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -76,7 +76,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ return ( <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> - <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}-header`}> + <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}Header`}> <EuiTitle size="s"> <h3 id="flyoutTitle"> {type === 'customField' ? i18n.ADD_CUSTOM_FIELD : i18n.CRATE_TEMPLATE} @@ -100,12 +100,12 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ /> ) : null} </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={`${dataTestSubj}-footer`}> + <EuiFlyoutFooter data-test-subj={`${dataTestSubj}Footer`}> <EuiFlexGroup justifyContent="flexStart"> <EuiFlexItem grow={false}> <EuiButtonEmpty onClick={onCloseFlyout} - data-test-subj={`${dataTestSubj}-cancel`} + data-test-subj={`${dataTestSubj}Cancel`} disabled={disabled} isLoading={isLoading} > @@ -117,7 +117,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ <EuiButton fill onClick={handleSaveField} - data-test-subj={`${dataTestSubj}-save`} + data-test-subj={`${dataTestSubj}Save`} disabled={disabled} isLoading={isLoading} > 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 bf9545724bd581..fdbac7eb714964 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 @@ -736,7 +736,7 @@ describe('ConfigureCases', () => { userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('customFieldFlyout-save')); + userEvent.click(screen.getByTestId('customFieldFlyoutSave')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -781,7 +781,7 @@ describe('ConfigureCases', () => { expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('customFieldFlyout-cancel')); + userEvent.click(screen.getByTestId('customFieldFlyoutCancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); expect(screen.queryByTestId('customFieldFlyout')).not.toBeInTheDocument(); @@ -796,7 +796,7 @@ describe('ConfigureCases', () => { userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('customFieldFlyout-save')); + userEvent.click(screen.getByTestId('customFieldFlyoutSave')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx deleted file mode 100644 index 508f124a7746c5..00000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { CustomFieldFlyout } from './flyout'; -import { customFieldsConfigurationMock } from '../../containers/mock'; -import { - MAX_CUSTOM_FIELD_LABEL_LENGTH, - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, -} from '../../../common/constants'; -import { CustomFieldTypes } from '../../../common/types/domain'; - -import * as i18n from './translations'; - -describe('CustomFieldFlyout ', () => { - let appMockRender: AppMockRenderer; - - const props = { - onCloseFlyout: jest.fn(), - onSaveField: jest.fn(), - isLoading: false, - disabled: false, - customField: null, - }; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - }); - - it('renders correctly', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - expect(await screen.findByTestId('custom-field-flyout-header')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-cancel')).toBeInTheDocument(); - expect(await screen.findByTestId('custom-field-flyout-save')).toBeInTheDocument(); - }); - - it('shows error if field label is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); - - userEvent.type(await screen.findByTestId('custom-field-label-input'), message); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR(i18n.FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) - ) - ).toBeInTheDocument(); - }); - - it('does not call onSaveField when error', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - expect( - await screen.findByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL.toLocaleLowerCase())) - ).toBeInTheDocument(); - - expect(props.onSaveField).not.toBeCalled(); - }); - - it('calls onCloseFlyout on cancel', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('custom-field-flyout-cancel')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - it('calls onCloseFlyout on close', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); - - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); - }); - - describe('Text custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'Default value' - ); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - defaultValue: 'Default value', - }); - }); - }); - - it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TEXT, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[0] }} /> - ); - - expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[0].label - ); - expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); - expect(await screen.findByTestId('text-custom-field-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('text-custom-field-default-value')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[0].defaultValue - ); - }); - - it('shows an error if default value is too long', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.paste( - await screen.findByTestId('text-custom-field-default-value'), - 'z'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) - ); - - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR( - i18n.DEFAULT_VALUE.toLowerCase(), - MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH - ) - ) - ).toBeInTheDocument(); - }); - }); - - describe('Toggle custom field', () => { - it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: false, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('calls onSaveField with the correct default value when a custom field is required', async () => { - appMockRender.render(<CustomFieldFlyout {...props} />); - - fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { - target: { value: CustomFieldTypes.TOGGLE }, - }); - - userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('toggle-custom-field-required')); - userEvent.click(await screen.findByTestId('custom-field-flyout-save')); - - await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ - key: expect.anything(), - label: 'Summary', - required: true, - type: CustomFieldTypes.TOGGLE, - defaultValue: false, - }); - }); - }); - - it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CustomFieldFlyout {...{ ...props, customField: customFieldsConfigurationMock[1] }} /> - ); - - expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( - 'value', - customFieldsConfigurationMock[1].label - ); - expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); - expect(await screen.findByTestId('toggle-custom-field-required')).toHaveAttribute('checked'); - expect(await screen.findByTestId('toggle-custom-field-default-value')).toHaveAttribute( - 'aria-checked', - 'true' - ); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx deleted file mode 100644 index e8fb5a937da9ad..00000000000000 --- a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useState } from 'react'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, -} from '@elastic/eui'; -import type { CustomFieldFormState } from './form'; -import { CustomFieldsForm } from './form'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; - -import * as i18n from './translations'; - -export interface CustomFieldFlyoutProps { - disabled: boolean; - isLoading: boolean; - onCloseFlyout: () => void; - onSaveField: (data: CustomFieldConfiguration) => void; - customField: CustomFieldConfiguration | null; -} - -const CustomFieldFlyoutComponent: React.FC<CustomFieldFlyoutProps> = ({ - onCloseFlyout, - onSaveField, - isLoading, - disabled, - customField, -}) => { - const dataTestSubj = 'custom-field-flyout'; - - const [formState, setFormState] = useState<CustomFieldFormState>({ - isValid: undefined, - submit: async () => ({ - isValid: false, - data: {}, - }), - }); - - const { submit } = formState; - - const handleSaveField = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid) { - onSaveField(data); - } - }, [onSaveField, submit]); - - return ( - <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> - <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}-header`}> - <EuiTitle size="s"> - <h3 id="flyoutTitle">{i18n.ADD_CUSTOM_FIELD}</h3> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <CustomFieldsForm initialValue={customField} onChange={setFormState} /> - </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={`${dataTestSubj}-footer`}> - <EuiFlexGroup justifyContent="flexStart"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - onClick={onCloseFlyout} - data-test-subj={`${dataTestSubj}-cancel`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButton - fill - onClick={handleSaveField} - data-test-subj={`${dataTestSubj}-save`} - disabled={disabled} - isLoading={isLoading} - > - {i18n.SAVE_FIELD} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexGroup> - </EuiFlyoutFooter> - </EuiFlyout> - ); -}; - -CustomFieldFlyoutComponent.displayName = 'CustomFieldFlyout'; - -export const CustomFieldFlyout = React.memo(CustomFieldFlyoutComponent); diff --git a/x-pack/plugins/cases/public/components/templates/connector.test.tsx b/x-pack/plugins/cases/public/components/templates/connector.test.tsx index 5aa19480199e47..b286e98fa185ae 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.test.tsx @@ -52,7 +52,6 @@ const defaultProps = { describe('Connector', () => { let appMockRender: AppMockRenderer; - const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); 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 2252a3fc001291..cc3c207e7ca931 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -11,10 +11,11 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import type { TemplateFormState } from './form'; import { TemplateForm } from './form'; -import { connectorsMock } from '../../containers/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import userEvent from '@testing-library/user-event'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; +import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_TAG_LENGTH } from '../../../common/constants'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -259,4 +260,168 @@ describe('TemplateForm', () => { }); }); }); + + it('serializes the custom fields data correctly', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + onChange: onChangeState, + }} + /> + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[3]; + + const textCustomField = await screen.findByTestId( + `${textField.key}-${textField.type}-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); + + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Template 1', + description: 'this is a first template', + tags: [], + caseFields: { + connectorId: 'none', + fields: null, + syncAlerts: true, + customFields: { + test_key_1: 'My text test value 1', + test_key_2: true, + test_key_4: true, + }, + }, + }); + }); + }); + + it('shows from state as invalid when template name missing', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), ''); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template description missing', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tags are more than 10', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const tagsArray = Array(MAX_TAGS_PER_TEMPLATE + 1).fill('foo'); + + const templateTags = await screen.findByTestId('template-tags'); + + tagsArray.forEach((tag) => { + userEvent.paste(within(templateTags).getByRole('combobox'), 'template-1'); + userEvent.keyboard('{enter}'); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + + it('shows from state as invalid when template tag is more than 50 characters', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const x = 'a'.repeat(MAX_TEMPLATE_TAG_LENGTH + 1); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), x); + userEvent.keyboard('{enter}'); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/templates/index.test.tsx b/x-pack/plugins/cases/public/components/templates/index.test.tsx new file mode 100644 index 00000000000000..0926db51e97908 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import { Templates } from '.'; +import * as i18n from './translations'; +import { templatesConfigurationMock } from '../../containers/mock'; + +describe('Templates', () => { + let appMockRender: AppMockRenderer; + + const props = { + disabled: false, + isLoading: false, + templates: [], + handleAddTemplate: jest.fn(), + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('renders templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, isLoading: true }} />); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', async () => { + appMockRender.render(<Templates {...{ ...props, disabled: true }} />); + + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(<Templates {...props} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(props.handleAddTemplate).toBeCalled(); + }); + + it('shows the experimental badge', async () => { + appMockRender.render(<Templates {...props} />); + + expect(await screen.findByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('shows error when templates reaches the limit', async () => { + const mockTemplates = []; + + for (let i = 0; i < 6; i++) { + mockTemplates.push({ + key: `field_key_${i + 1}`, + name: `template_${i + 1}`, + description: 'random foobar', + caseFields: null, + }); + } + const templates = [...templatesConfigurationMock, ...mockTemplates]; + + appMockRender.render(<Templates {...{ ...props, templates }} />); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByText(i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH))); + expect(await screen.findByTestId('add-template')).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index 49824367e4cad7..d0dc058d4bead9 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiButtonEmpty, EuiPanel, @@ -13,11 +13,12 @@ import { EuiSpacer, EuiFlexGroup, EuiFlexItem, + EuiText, } from '@elastic/eui'; - +import { MAX_TEMPLATES_LENGTH } from '../../../common/constants'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; import { useCasesContext } from '../cases_context/use_cases_context'; import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; -import type { CasesConfigurationUITemplate } from '../../../common/ui'; import * as i18n from './translations'; import { TemplatesList } from './templates_list'; @@ -36,16 +37,17 @@ const TemplatesComponent: React.FC<Props> = ({ }) => { const { permissions } = useCasesContext(); const canAddTemplates = permissions.create && permissions.update; + const [error, setError] = useState<boolean>(false); const onAddCustomField = useCallback(() => { - // if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) { - // setError(true); - // return; - // } + if (templates.length === MAX_TEMPLATES_LENGTH && !error) { + setError(true); + return; + } handleAddTemplate(); - // setError(false); - }, [handleAddTemplate]); + setError(false); + }, [handleAddTemplate, error, templates]); return ( <EuiDescribedFormGroup @@ -65,15 +67,13 @@ const TemplatesComponent: React.FC<Props> = ({ {templates.length ? ( <> <TemplatesList templates={templates} /> - {/* {error ? ( + {error ? ( <EuiFlexGroup justifyContent="center"> <EuiFlexItem grow={false}> - <EuiText color="danger"> - {i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)} - </EuiText> + <EuiText color="danger">{i18n.MAX_TEMPLATE_LIMIT(MAX_TEMPLATES_LENGTH)}</EuiText> </EuiFlexItem> </EuiFlexGroup> - ) : null} */} + ) : null} </> ) : null} <EuiSpacer size="m" /> @@ -90,7 +90,7 @@ const TemplatesComponent: React.FC<Props> = ({ <EuiFlexItem grow={false}> <EuiButtonEmpty isLoading={isLoading} - isDisabled={disabled} + isDisabled={disabled || error} size="s" onClick={onAddCustomField} iconType="plusInCircle" diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx new file mode 100644 index 00000000000000..86094389d94fb2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { templatesConfigurationMock } from '../../containers/mock'; +import { TemplatesList } from './templates_list'; + +describe('TemplatesList', () => { + let appMockRender: AppMockRenderer; + + const props = { + templates: templatesConfigurationMock, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(<TemplatesList {...props} />); + + expect(screen.getByTestId('templates-list')).toBeInTheDocument(); + }); + + it('renders all templates', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: templatesConfigurationMock }} /> + ); + + expect(await screen.findByTestId('templates-list')).toBeInTheDocument(); + + templatesConfigurationMock.forEach((template) => + expect(screen.getByTestId(`template-${template.key}`)).toBeInTheDocument() + ); + }); + + it('renders template details correctly', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[3]] }} /> + ); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + expect( + await screen.findByTestId(`template-${templatesConfigurationMock[3].key}`) + ).toBeInTheDocument(); + expect(await screen.findByText(`${templatesConfigurationMock[3].name}`)).toBeInTheDocument(); + const tags = templatesConfigurationMock[3].tags; + + tags?.forEach((tag, index) => + expect( + screen.getByTestId(`${templatesConfigurationMock[3].key}-tag-${index}`) + ).toBeInTheDocument() + ); + }); + + it('renders empty state correctly', () => { + appMockRender.render(<TemplatesList {...{ ...props, templates: [] }} />); + + expect(screen.queryAllByTestId(`template-`, { exact: false })).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx index c18fbfc1795736..c4eec8c618f580 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -6,7 +6,15 @@ */ import React from 'react'; -import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiBadge, + useEuiTheme, +} from '@elastic/eui'; import type { CasesConfigurationUITemplate } from '../../../common/ui'; export interface Props { @@ -15,6 +23,7 @@ export interface Props { const TemplatesListComponent: React.FC<Props> = (props) => { const { templates } = props; + const { euiTheme } = useEuiTheme(); return templates.length ? ( <> @@ -25,7 +34,7 @@ const TemplatesListComponent: React.FC<Props> = (props) => { <React.Fragment key={template.key}> <EuiPanel paddingSize="s" - data-test-subj={`custom-field-${template.key}`} + data-test-subj={`template-${template.key}`} hasShadow={false} > <EuiFlexGroup alignItems="center" gutterSize="s"> @@ -36,6 +45,17 @@ const TemplatesListComponent: React.FC<Props> = (props) => { <h4>{template.name}</h4> </EuiText> </EuiFlexItem> + {template.tags?.length + ? template.tags.map((tag, index) => ( + <EuiBadge + key={`${template.key}-tag-${index}`} + data-test-subj={`${template.key}-tag-${index}`} + color={euiTheme.colors.body} + > + {tag} + </EuiBadge> + )) + : null} </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index 5fa7f7167ac720..3bc9f580652a95 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -59,3 +59,9 @@ export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { defaultMessage: 'External Connector Fields', }); + +export const MAX_TEMPLATE_LIMIT = (maxTemplates: number) => + i18n.translate('xpack.cases.templates.maxTemplateLimit', { + values: { maxTemplates }, + defaultMessage: 'Maximum number of {maxTemplates} templates reached.', + }); From 202421209861ed3813892aaf56884a7535f64f74 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 27 May 2024 14:43:01 +0100 Subject: [PATCH 09/28] add template section description --- .../case_form_fields/index.test.tsx | 76 +++++++------------ .../components/case_form_fields/index.tsx | 20 ++--- .../components/custom_fields/text/create.tsx | 2 +- .../public/components/templates/connector.tsx | 2 +- .../public/components/templates/form.test.tsx | 27 +++++++ .../components/templates/form_fields.tsx | 3 +- .../components/templates/translations.ts | 3 +- .../public/components/templates/types.ts | 19 +++-- 8 files changed, 83 insertions(+), 69 deletions(-) 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 bebdfe29426a97..a650812895c308 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 @@ -17,6 +17,7 @@ import { userProfiles } from '../../containers/user_profiles/api.mock'; import { CaseFormFields } from '.'; import userEvent from '@testing-library/user-event'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; jest.mock('../../containers/user_profiles/api'); @@ -27,6 +28,7 @@ describe('CaseFormFields', () => { let appMock: AppMockRenderer; const onSubmit = jest.fn(); const defaultProps = { + isLoading: false, configurationCustomFields: [], draftStorageKey: '', }; @@ -79,6 +81,7 @@ describe('CaseFormFields', () => { appMock.render( <FormTestComponent onSubmit={onSubmit}> <CaseFormFields + isLoading={false} configurationCustomFields={customFieldsConfigurationMock} draftStorageKey="" /> @@ -175,10 +178,6 @@ describe('CaseFormFields', () => { configurationCustomFields: customFieldsConfigurationMock, }; - appMock = createAppMockRenderer({ - features: { alerts: { sync: false, enabled: true } }, - }); - appMock.render( <FormTestComponent onSubmit={onSubmit}> <CaseFormFields {...newProps} /> @@ -209,7 +208,7 @@ describe('CaseFormFields', () => { caseFields: { category: null, tags: [], - + syncAlerts: true, customFields: { test_key_1: 'My text test value 1', test_key_2: false, @@ -222,58 +221,41 @@ describe('CaseFormFields', () => { }); }); - describe('Assignees', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); + it('calls onSubmit with assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, }); - it('calls onSubmit with assignees', async () => { - const license = licensingMock.createLicense({ - license: { type: 'platinum' }, - }); - - appMock = createAppMockRenderer({ license }); - - appMock.render( - <FormTestComponent onSubmit={onSubmit}> - <CaseFormFields {...defaultProps} /> - </FormTestComponent> - ); + appMock = createAppMockRenderer({ license }); - const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + appMock.render( + <FormTestComponent onSubmit={onSubmit}> + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); - userEvent.click(await within(assigneesComboBox).findByTestId('comboBoxToggleListButton')); + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); - userEvent.paste(await within(assigneesComboBox).findByTestId('comboBoxSearchInput'), 'dr'); + userEvent.click(await within(assigneesComboBox).findByTestId('comboBoxToggleListButton')); - act(() => { - jest.advanceTimersByTime(1000); - }); + await waitForEuiPopoverOpen(); - userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); + userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); - userEvent.click(screen.getByText('Submit')); + userEvent.click(screen.getByText('Submit')); - await waitFor(() => { - expect(onSubmit).toBeCalledWith( - { - caseFields: { - category: null, - tags: [], - assignees: [{ uid: userProfiles[0].uid }], - }, + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + caseFields: { + category: null, + tags: [], + syncAlerts: true, + assignees: [{ uid: userProfiles[0].uid }], }, - true - ); - }); + }, + true + ); }); }); }); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index 2109c011a325ca..9d6a1c6f31c0bf 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -6,7 +6,6 @@ */ import React, { memo } from 'react'; -import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { EuiFlexGroup } from '@elastic/eui'; import { Title } from '../create/title'; import { Tags } from '../create/tags'; @@ -20,41 +19,42 @@ import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; import type { CasesConfigurationUI } from '../../containers/types'; interface Props { + isLoading: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; draftStorageKey: string; } const CaseFormFieldsComponent: React.FC<Props> = ({ + isLoading, configurationCustomFields, draftStorageKey, }) => { - const { isSubmitting } = useFormContext(); const { caseAssignmentAuthorized, isSyncAlertsEnabled } = useCasesFeatures(); return ( <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> - <Title isLoading={isSubmitting} path="caseFields.title" /> + <Title isLoading={isLoading} path="caseFields.title" /> {caseAssignmentAuthorized ? ( - <Assignees isLoading={isSubmitting} path="caseFields.assignees" /> + <Assignees isLoading={isLoading} path="caseFields.assignees" /> ) : null} - <Tags isLoading={isSubmitting} path="caseFields.tags" /> + <Tags isLoading={isLoading} path="caseFields.tags" /> - <Category isLoading={isSubmitting} path="caseFields.category" /> + <Category isLoading={isLoading} path="caseFields.category" /> - <Severity isLoading={isSubmitting} path="caseFields.severity" /> + <Severity isLoading={isLoading} path="caseFields.severity" /> <Description - isLoading={isSubmitting} + isLoading={isLoading} path="caseFields.description" draftStorageKey={draftStorageKey} /> {isSyncAlertsEnabled ? ( - <SyncAlertsToggle isLoading={isSubmitting} path="caseFields.syncAlerts" /> + <SyncAlertsToggle isLoading={isLoading} path="caseFields.syncAlerts" /> ) : null} <CustomFields - isLoading={isSubmitting} + isLoading={isLoading} path="caseFields.customFields" setAsOptional={true} configurationCustomFields={configurationCustomFields} 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 ae34f49f790a9a..1ccc1961e90fa4 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 @@ -35,12 +35,12 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ component={TextField} label={label} componentProps={{ + labelAppend: setAsOptional ? OptionalFieldLabel : null, euiFieldProps: { 'data-test-subj': `${key}-text-create-custom-field`, fullWidth: true, disabled: isLoading, isLoading, - labelAppend: setAsOptional ? OptionalFieldLabel : null, }, }} /> diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 7a7972a34d94a5..81de9370353e70 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -38,7 +38,7 @@ const ConnectorComponent: React.FC<Props> = ({ const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; - const connectorId = schema.caseFields?.connectorId ?? ''; + const connectorId = schema?.caseFields ? schema.caseFields.connectorId : ''; const connectorIdConfig = getConnectorsFormValidators({ config: connectorId as FieldConfig, 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 cc3c207e7ca931..012170a29f6421 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -16,6 +16,8 @@ import userEvent from '@testing-library/user-event'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_TAG_LENGTH } from '../../../common/constants'; +import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { connect } from 'http2'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -107,6 +109,20 @@ describe('TemplateForm', () => { ).toHaveValue('case description'); }); + it('renders case fields as optional', async () => { + appMockRenderer.render(<TemplateForm {...defaultProps} />); + + const title = await screen.findByTestId('caseTitle'); + const tags = await screen.findByTestId('caseTags'); + const category = await screen.findByTestId('caseCategory'); + const description = await screen.findByTestId('caseDescription'); + + expect(await within(title).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(tags).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(category).findByTestId('form-optional-field-label')).toBeInTheDocument(); + expect(await within(description).findByTestId('form-optional-field-label')).toBeInTheDocument(); + }); + it('serializes the template field data correctly', async () => { let formState: TemplateFormState; @@ -219,6 +235,10 @@ describe('TemplateForm', () => { /> ); + const connectors = await screen.findByTestId('caseConnectors'); + + expect(await within(connectors).findByTestId('form-optional-field-label')).toBeInTheDocument(); + await waitFor(() => { expect(formState).not.toBeUndefined(); }); @@ -287,6 +307,12 @@ describe('TemplateForm', () => { 'this is a first template' ); + const customFieldsEle = await screen.findByTestId('caseCustomFields'); + + expect(await within(customFieldsEle).findAllByTestId('form-optional-field-label')).toHaveLength( + customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length + ); + const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[3]; @@ -295,6 +321,7 @@ describe('TemplateForm', () => { ); userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'My text test value 1'); userEvent.click( diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 29d42ac1da9fb2..ee8f5e97efcad5 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -81,10 +81,11 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ <CaseFormFields configurationCustomFields={configurationCustomFields} draftStorageKey={draftStorageKey} + isLoading={isSubmitting} /> ), }), - [configurationCustomFields, draftStorageKey] + [isSubmitting, configurationCustomFields, draftStorageKey] ); const thirdStep = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index 3bc9f580652a95..db06e148f23854 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -14,7 +14,8 @@ export const TEMPLATE_TITLE = i18n.translate('xpack.cases.templates.title', { }); export const TEMPLATE_DESCRIPTION = i18n.translate('xpack.cases.templates.description', { - defaultMessage: 'Template description.', + defaultMessage: + 'Add Case Templates to automatically define the case fields while creating a new case. A user can choose to create an empty case or based on a preset template. Templates allow to auto-populate values when creating new cases.', }); export const NO_TEMPLATES = i18n.translate('xpack.cases.templates.noTemplates', { diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index 567d5e468f83ef..5dcb9837c24ae8 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -11,13 +11,16 @@ import type { TemplateConfiguration, } from '../../../common/types/domain'; +export type CaseFieldsProps = Omit< + CaseBaseOptionalFields, + 'customFields' | 'connector' | 'settings' +> & { + customFields?: Record<string, string | boolean>; + connectorId?: string; + fields?: ConnectorTypeFields['fields']; + syncAlerts?: boolean; +}; + export type TemplateFormProps = Omit<TemplateConfiguration, 'caseFields'> & { - caseFields: - | (Omit<CaseBaseOptionalFields, 'customFields' | 'connector' | 'settings'> & { - customFields?: Record<string, string | boolean>; - connectorId?: string; - fields?: ConnectorTypeFields['fields']; - syncAlerts?: boolean; - }) - | null; + caseFields: CaseFieldsProps | null; }; From 81f3d0beca40f90e0b1f7dee3aee28c16e731f92 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 28 May 2024 00:01:31 +0100 Subject: [PATCH 10/28] remove path from case fields --- .../case_form_fields/custom_fields.tsx | 1 - .../components/case_form_fields/index.tsx | 23 +-- .../category/category_form_field.tsx | 4 +- .../components/configure_cases/index.tsx | 14 +- .../components/connectors/fields_form.tsx | 5 +- .../connectors/jira/case_fields.tsx | 17 +- .../connectors/jira/search_issues.tsx | 5 +- .../connectors/resilient/case_fields.tsx | 9 +- .../servicenow_itsm_case_fields.tsx | 17 +- .../servicenow/servicenow_sir_case_fields.tsx | 15 +- .../public/components/connectors/types.ts | 1 - .../public/components/create/assignees.tsx | 2 +- .../public/components/create/category.tsx | 3 +- .../public/components/create/description.tsx | 5 +- .../public/components/create/severity.tsx | 5 +- .../components/create/sync_alerts_toggle.tsx | 9 +- .../cases/public/components/create/title.tsx | 2 +- .../components/custom_fields/text/create.tsx | 5 +- .../custom_fields/toggle/create.tsx | 5 +- .../public/components/custom_fields/types.ts | 1 - .../public/components/templates/connector.tsx | 11 +- .../public/components/templates/form.tsx | 3 +- .../components/templates/form_fields.tsx | 6 +- .../public/components/templates/schema.tsx | 156 +++++++++--------- .../public/components/templates/types.ts | 8 +- .../public/components/templates/utils.ts | 22 ++- 26 files changed, 160 insertions(+), 194 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx index d66c125ada42b0..e430be8be8efad 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -43,7 +43,6 @@ const CustomFieldsComponent: React.FC<Props> = ({ isLoading={isLoading} customFieldConfiguration={customField} key={customField.key} - path={path} setAsOptional={setAsOptional} /> ); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index 9d6a1c6f31c0bf..13a0dd959605c5 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -33,29 +33,20 @@ const CaseFormFieldsComponent: React.FC<Props> = ({ return ( <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> - <Title isLoading={isLoading} path="caseFields.title" /> - {caseAssignmentAuthorized ? ( - <Assignees isLoading={isLoading} path="caseFields.assignees" /> - ) : null} - <Tags isLoading={isLoading} path="caseFields.tags" /> + <Title isLoading={isLoading} /> + {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} + <Tags isLoading={isLoading} /> - <Category isLoading={isLoading} path="caseFields.category" /> + <Category isLoading={isLoading} /> - <Severity isLoading={isLoading} path="caseFields.severity" /> + <Severity isLoading={isLoading} /> - <Description - isLoading={isLoading} - path="caseFields.description" - draftStorageKey={draftStorageKey} - /> + <Description isLoading={isLoading} draftStorageKey={draftStorageKey} /> - {isSyncAlertsEnabled ? ( - <SyncAlertsToggle isLoading={isLoading} path="caseFields.syncAlerts" /> - ) : null} + {isSyncAlertsEnabled ? <SyncAlertsToggle isLoading={isLoading} /> : null} <CustomFields isLoading={isLoading} - path="caseFields.customFields" setAsOptional={true} configurationCustomFields={configurationCustomFields} /> diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index f74946c4a0c68c..7e51c8fabcb716 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -23,7 +23,6 @@ interface Props { isLoading: boolean; availableCategories: string[]; formRowProps?: Partial<EuiFormRowProps>; - path?: string; } type CategoryField = CaseUI['category'] | undefined; @@ -64,10 +63,9 @@ const CategoryFormFieldComponent: React.FC<Props> = ({ isLoading, availableCategories, formRowProps, - path, }) => { return ( - <UseField<CategoryField> path={path ?? 'category'} config={getCategoryConfig()}> + <UseField<CategoryField> path="category" config={getCategoryConfig()}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); 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 31852fade7e455..173f8de6df585c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -360,14 +360,18 @@ export const ConfigureCases: React.FC = React.memo(() => { } if (flyoutType === 'template') { - const { caseFields, ...rest } = data as TemplateFormProps; const { connectorId, fields, customFields: templateCustomFields, syncAlerts = false, + key, + name, + templateTags, + templateDescription, ...otherCaseFields - } = caseFields ?? {}; + } = data as TemplateFormProps; + const transformedCustomFields = templateCustomFields ? transformCustomFieldsData(templateCustomFields, customFields) : []; @@ -378,7 +382,10 @@ export const ConfigureCases: React.FC = React.memo(() => { : getNoneConnector(); const transformedData: TemplateConfiguration = { - ...rest, + key, + name, + description: templateDescription, + tags: templateTags, caseFields: { ...otherCaseFields, connector: transformedConnector, @@ -386,6 +393,7 @@ export const ConfigureCases: React.FC = React.memo(() => { settings: { syncAlerts }, }, }; + const updatedTemplates = addOrReplaceField(templates, transformedData); persistCaseConfigure({ diff --git a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx index 3930b0d64b960e..2fe99f7dff465c 100644 --- a/x-pack/plugins/cases/public/components/connectors/fields_form.tsx +++ b/x-pack/plugins/cases/public/components/connectors/fields_form.tsx @@ -13,10 +13,9 @@ import { getCaseConnectors } from '.'; interface Props { connector: CaseActionConnector | null; - path?: 'caseFields.fields' | 'fields'; } -const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector, path = 'fields' }) => { +const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector }) => { const { caseConnectorsRegistry } = getCaseConnectors(); if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { @@ -38,7 +37,7 @@ const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector, path = 'fiel } > <div data-test-subj={'connector-fields'}> - <FieldsComponent path={path} connector={connector} key={connector.id} /> + <FieldsComponent connector={connector} key={connector.id} /> </div> </Suspense> ) : null} diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx index 9962a291b06c8f..fdc8fbc4aa3afb 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -19,19 +19,16 @@ import type { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; +import { JiraFieldsType } from '@kbn/cases-plugin/common/types/domain'; const { emptyField } = fieldValidators; -const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ - connector, - path = 'fields', -}) => { - const [formData] = useFormData(); +const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector }) => { + const [{ fields }] = useFormData<{ fields: JiraFieldsType }>(); const { http } = useKibana().services; - const fieldsData = path === 'caseFields.fields' ? formData?.caseFields?.fields : formData?.fields; - const { issueType } = fieldsData ?? {}; + const { issueType } = fields ?? {}; const { isLoading: isLoadingIssueTypesData, @@ -80,7 +77,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ return ( <div data-test-subj={'connector-fields-jira'}> <UseField - path={`${path}.issueType`} + path="fields.priority" component={SelectField} config={{ label: i18n.ISSUE_TYPE, @@ -111,7 +108,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ <div style={{ display: hasParent ? 'block' : 'none' }}> <EuiFlexGroup> <EuiFlexItem> - <SearchIssues path={path} actionConnector={connector} /> + <SearchIssues actionConnector={connector} /> </EuiFlexItem> </EuiFlexGroup> <EuiSpacer size="m" /> @@ -120,7 +117,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.priority`} + path="fields.priority" component={SelectField} config={{ label: i18n.PRIORITY, diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 531d9fd36e0491..27df975ac58646 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx @@ -20,10 +20,9 @@ import * as i18n from './translations'; interface Props { actionConnector?: ActionConnector; - path?: string; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, path }) => { +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { const [query, setQuery] = useState<string | null>(null); const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( [] @@ -41,7 +40,7 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, path }) => { const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); return ( - <UseField path={`${path}.parent`}> + <UseField path="fields.parent"> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); diff --git a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx index a39a8eaf00aa22..e8260a69a33014 100644 --- a/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/resilient/case_fields.tsx @@ -21,10 +21,7 @@ import { useGetSeverity } from './use_get_severity'; import * as i18n from './translations'; -const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ - connector, - path = 'fields', -}) => { +const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector }) => { const { http } = useKibana().services; const { @@ -72,7 +69,7 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = return ( <span data-test-subj={'connector-fields-resilient'}> - <UseField<string[]> path={`${path}.incidentTypes`} config={{ defaultValue: [] }}> + <UseField<string[]> path="fields.incidentTypes" config={{ defaultValue: [] }}> {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -112,7 +109,7 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = </UseField> <EuiSpacer size="m" /> <UseField - path={`${path}.severityCode`} + path="fields.severityCode" component={SelectField} config={{ label: i18n.SEVERITY_LABEL, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 187716bcba84b5..968f9fa0583714 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -17,6 +17,7 @@ import * as i18n from './translations'; import type { ConnectorFieldsProps } from '../types'; import { useKibana } from '../../../common/lib/kibana'; +import type { ServiceNowITSMFieldsType } from '../../../../common/types/domain'; import { useGetChoices } from './use_get_choices'; import type { Fields } from './types'; import { choicesToEuiOptions } from './helpers'; @@ -33,13 +34,11 @@ const defaultFields: Fields = { const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector, - path = 'fields', }) => { const form = useFormContext(); - const [formData] = useFormData(); - const fieldsData = path === 'caseFields.fields' ? formData?.caseFields?.fields : formData?.fields; + const [{ fields }] = useFormData<{ fields: ServiceNowITSMFieldsType }>(); - const { category = null } = fieldsData ?? {}; + const { category = null } = fields ?? {}; const { http } = useKibana().services; const showConnectorWarning = connector.isDeprecated; @@ -107,7 +106,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.urgency`} + path="fields.urgency" component={SelectField} config={{ label: i18n.URGENCY, @@ -128,7 +127,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.severity`} + path="fields.severity" component={SelectField} config={{ label: i18n.SEVERITY, @@ -147,7 +146,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp </EuiFlexItem> <EuiFlexItem> <UseField - path={`${path}.impact`} + path="fields.impact" component={SelectField} config={{ label: i18n.IMPACT, @@ -168,7 +167,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.category`} + path="fields.category" component={SelectField} config={{ label: i18n.CATEGORY, @@ -188,7 +187,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<ConnectorFieldsProp </EuiFlexItem> <EuiFlexItem> <UseField - path={`${path}.subcategory`} + path="fields.subcategory" component={SelectField} config={{ label: i18n.SUBCATEGORY, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 0b682909d046eb..e07fcc204c9da6 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -33,7 +33,6 @@ const defaultFields: Fields = { const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector, - path = 'fields', }) => { const form = useFormContext(); const [{ fields }] = useFormData<{ fields: ServiceNowSIRFieldsType }>(); @@ -101,7 +100,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.destIp`} + path="fields.destIp" config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -115,7 +114,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path={`${path}.sourceIp`} + path="fields.sourceIp" config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -131,7 +130,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.malwareUrl`} + path="fields.malwareUrl" config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -145,7 +144,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path={`${path}.malwareHash`} + path="fields.malwareHash" config={{ defaultValue: true }} component={CheckBoxField} componentProps={{ @@ -165,7 +164,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.priority`} + path="fields.priority" component={SelectField} config={{ label: i18n.PRIORITY, @@ -186,7 +185,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps <EuiFlexGroup> <EuiFlexItem> <UseField - path={`${path}.category`} + path="fields.category" component={SelectField} config={{ label: i18n.CATEGORY, @@ -206,7 +205,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<ConnectorFieldsProps </EuiFlexItem> <EuiFlexItem> <UseField - path={`${path}.subcategory`} + path="fields.subcategory" component={SelectField} config={{ label: i18n.SUBCATEGORY, diff --git a/x-pack/plugins/cases/public/components/connectors/types.ts b/x-pack/plugins/cases/public/components/connectors/types.ts index e9c2edfb154dcd..a4870fd0748f07 100644 --- a/x-pack/plugins/cases/public/components/connectors/types.ts +++ b/x-pack/plugins/cases/public/components/connectors/types.ts @@ -37,7 +37,6 @@ export interface CaseConnectorsRegistry { export interface ConnectorFieldsProps { connector: CaseActionConnector; - path?: 'caseFields.fields' | 'fields'; } export interface ConnectorFieldsPreviewProps<TFields> { diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index 48f3b79338aa11..5eaaf02275d11b 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -246,7 +246,7 @@ const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm, path }) return ( <UseField - path={path ?? 'assignees'} + path="assignees" config={getConfig()} component={AssigneesFieldComponent} componentProps={{ diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/create/category.tsx index 5d00a11f4e20a0..1accf7fad7511b 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/create/category.tsx @@ -15,7 +15,7 @@ interface Props { path?: string; } -const CategoryComponent: React.FC<Props> = ({ isLoading, path }) => { +const CategoryComponent: React.FC<Props> = ({ isLoading }) => { const { isLoading: isLoadingCategories, data: categories = [] } = useGetCategories(); return ( @@ -23,7 +23,6 @@ const CategoryComponent: React.FC<Props> = ({ isLoading, path }) => { isLoading={isLoading || isLoadingCategories} availableCategories={categories} formRowProps={{ labelAppend: OptionalFieldLabel }} - path={path} /> ); }; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 4409ef9d8d4d2e..5c512e701c123b 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -13,19 +13,18 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; draftStorageKey: string; - path?: string; } export const fieldName = 'description'; -const DescriptionComponent: React.FC<Props> = ({ isLoading, draftStorageKey, path }) => { +const DescriptionComponent: React.FC<Props> = ({ isLoading, draftStorageKey }) => { const [{ title, tags }] = useFormData({ watch: ['title', 'tags'] }); const editorRef = useRef<Record<string, unknown>>(); const disabledUiPlugins = [LensPluginId]; return ( <UseField - path={path ?? fieldName} + path={fieldName} component={MarkdownEditorForm} componentProps={{ id: fieldName, diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx index e69980c02d1caf..b42ab4eb3bd922 100644 --- a/x-pack/plugins/cases/public/components/create/severity.tsx +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -18,12 +18,11 @@ import { SEVERITY_TITLE } from '../severity/translations'; interface Props { isLoading: boolean; - path?: string; } -const SeverityComponent: React.FC<Props> = ({ isLoading, path }) => ( +const SeverityComponent: React.FC<Props> = ({ isLoading }) => ( <UseField<CaseSeverity> - path={path ?? 'severity'} + path="severity" componentProps={{ isLoading, }} diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index c5dda58ddf88cb..15e5da6019558c 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -16,16 +16,11 @@ interface Props { } const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading, path }) => { - const [formData] = useFormData(); - - const syncAlerts = - path && path === 'caseFields.syncAlerts' - ? Boolean(formData?.caseFields?.syncAlerts) - : Boolean(formData?.syncAlerts); + const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); return ( <UseField - path={path ?? 'syncAlerts'} + path="syncAlerts" component={ToggleField} config={{ defaultValue: true }} componentProps={{ diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx index 2807da9d30eace..3e309b6e91e703 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -17,7 +17,7 @@ interface Props { const TitleComponent: React.FC<Props> = ({ isLoading, path }) => ( <CommonUseField - path={path ?? 'title'} + path="title" componentProps={{ idAria: 'caseTitle', 'data-test-subj': 'caseTitle', 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 1ccc1961e90fa4..4fae9d7b4816dc 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 @@ -16,7 +16,6 @@ import { OptionalFieldLabel } from '../../create/optional_field_label'; const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, - path, setAsOptional, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; @@ -26,11 +25,9 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ ...(defaultValue && { defaultValue: String(defaultValue) }), }); - const newPath = path ?? 'customFields'; - return ( <UseField - path={`${newPath}.${key}`} + path={`customFields.${key}`} config={config} component={TextField} label={label} 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 23abeb37ead40c..2d3f51bc4f678a 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 @@ -14,15 +14,12 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, - path, }) => { const { key, label, defaultValue } = customFieldConfiguration; - const newPath = path ?? 'customFields'; - return ( <UseField - path={`${newPath}.${key}`} + path={`customFields.${key}`} component={ToggleField} config={{ defaultValue: defaultValue ? defaultValue : false }} key={key} 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 72c96b4bb564d3..b735d4ca316b06 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -30,7 +30,6 @@ export interface CustomFieldType<T extends CaseUICustomField> { Create: React.FC<{ customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; - path?: string; setAsOptional?: boolean; }>; } diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 81de9370353e70..1edcd60c32e9ac 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -32,16 +32,15 @@ const ConnectorComponent: React.FC<Props> = ({ path, configurationConnectorId, }) => { - const [{ caseFields }] = useFormData({ watch: ['caseFields.connectorId'] }); - const connector = getConnectorById(caseFields?.connectorId, connectors) ?? null; + const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); + const connector = getConnectorById(connectorId, connectors) ?? null; const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; - const connectorId = schema?.caseFields ? schema.caseFields.connectorId : ''; const connectorIdConfig = getConnectorsFormValidators({ - config: connectorId as FieldConfig, + config: schema.connectorId as FieldConfig, connectors, }); @@ -57,7 +56,7 @@ const ConnectorComponent: React.FC<Props> = ({ <EuiFlexGroup> <EuiFlexItem> <UseField - path={path ?? 'connectorId'} + path="connectorId" config={connectorIdConfig} component={ConnectorSelector} defaultValue={configurationConnectorId} @@ -71,7 +70,7 @@ const ConnectorComponent: React.FC<Props> = ({ /> </EuiFlexItem> <EuiFlexItem> - <ConnectorFieldsForm path={'caseFields.fields'} connector={connector} /> + <ConnectorFieldsForm connector={connector} /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 9bca4ae3b82c05..e9a23a50ff5e3c 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -42,8 +42,7 @@ const FormComponent: React.FC<Props> = ({ defaultValue: initialValue ?? { key: keyDefaultValue, name: '', - description: '', - caseFields: null, + templateDescription: '', }, options: { stripEmptyFields: false }, schema, diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index ee8f5e97efcad5..9947b68292eb66 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -55,9 +55,9 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ }, }} /> - <Tags isLoading={isSubmitting} path="tags" dataTestSubject="template-tags" /> + <Tags isLoading={isSubmitting} path="templateTags" dataTestSubject="template-tags" /> <UseField - path="description" + path="templateDescription" component={TextField} componentProps={{ euiFieldProps: { @@ -97,7 +97,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ connectors={connectors} isLoading={isSubmitting} configurationConnectorId={configurationConnectorId} - path="caseFields.connectorId" + path="connectorId" /> </div> ), diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 45eebf48ae8578..c4f99cd302fe53 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -40,7 +40,7 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, - description: { + templateDescription: { label: i18n.DESCRIPTION, validations: [ { @@ -48,7 +48,7 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, - tags: { + templateTags: { label: i18n.TAGS, helpText: i18n.TEMPLATE_TAGS_HELP, labelAppend: OptionalFieldLabel, @@ -79,84 +79,82 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, - caseFields: { - title: { - label: i18n.NAME, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: maxLengthField({ - length: MAX_TITLE_LENGTH, - message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + title: { + label: i18n.NAME, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + }), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: maxLengthField({ + length: MAX_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + }), + }, + ], + }, + tags: { + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + limit: MAX_LENGTH_PER_TAG, }), - }, - ], - }, - description: { - label: i18n.DESCRIPTION, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: maxLengthField({ - length: MAX_DESCRIPTION_LENGTH, - message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + limit: MAX_TAGS_PER_CASE, }), - }, - ], - }, - tags: { - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: ({ value }: { value: string | string[] }) => - validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string | string[] }) => - validateMaxLength({ - value, - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - limit: MAX_LENGTH_PER_TAG, - }), - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string[] }) => - validateMaxTagsLength({ - value, - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - limit: MAX_TAGS_PER_CASE, - }), - }, - ], - }, - severity: { - label: SEVERITY_TITLE, - labelAppend: OptionalFieldLabel, - }, - assignees: { - labelAppend: OptionalFieldLabel, - }, - category: { - labelAppend: OptionalFieldLabel, - }, - connectorId: { - labelAppend: OptionalFieldLabel, - label: i18n.CONNECTORS, - defaultValue: 'none', - }, - fields: { - defaultValue: null, - }, - syncAlerts: { - helpText: i18n.SYNC_ALERTS_HELP, - labelAppend: OptionalFieldLabel, - defaultValue: true, - }, + }, + ], + }, + severity: { + label: SEVERITY_TITLE, + labelAppend: OptionalFieldLabel, + }, + assignees: { + labelAppend: OptionalFieldLabel, + }, + category: { + labelAppend: OptionalFieldLabel, + }, + connectorId: { + labelAppend: OptionalFieldLabel, + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: { + defaultValue: null, + }, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + labelAppend: OptionalFieldLabel, + defaultValue: true, }, }; diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index 5dcb9837c24ae8..0c490ac1fac1cf 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -21,6 +21,8 @@ export type CaseFieldsProps = Omit< syncAlerts?: boolean; }; -export type TemplateFormProps = Omit<TemplateConfiguration, 'caseFields'> & { - caseFields: CaseFieldsProps | null; -}; +export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & + CaseFieldsProps & { + templateTags: string[]; + templateDescription: string; + }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 0bd035b75a9b97..719644e48ab1bb 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -6,13 +6,14 @@ */ import { getConnectorsFormSerializer, isEmptyValue } from '../utils'; +import { ConnectorTypeFields } from '../../../common/types/domain'; import type { TemplateFormProps } from './types'; export const removeEmptyFields = ( - fields: TemplateFormProps['caseFields'] | Record<string, string | boolean> | null | undefined -): TemplateFormProps['caseFields'] => { - if (fields) { - return Object.entries(fields).reduce((acc, [key, value]) => { + data: Omit<TemplateFormProps, 'fields'> | Record<string, string | boolean> | null | undefined +): Omit<TemplateFormProps, 'fields'> | Record<string, string | boolean> | null => { + if (data) { + return Object.entries(data).reduce((acc, [key, value]) => { let initialValue = {}; if (key === 'customFields') { @@ -38,17 +39,14 @@ export const removeEmptyFields = ( }; export const templateSerializer = <T extends TemplateFormProps | null>(data: T): T => { - if (data !== null && data.caseFields) { - const { fields, ...rest } = data.caseFields; - const connectorFields = getConnectorsFormSerializer({ fields: fields ?? null }); + if (data !== null) { + const { fields, ...rest } = data; const serializedFields = removeEmptyFields(rest); + const connectorFields = getConnectorsFormSerializer({ fields: fields ?? null }); return { - ...data, - caseFields: { - ...serializedFields, - fields: connectorFields.fields, - } as TemplateFormProps['caseFields'], + ...serializedFields, + fields: connectorFields.fields as ConnectorTypeFields['fields'], }; } From 95d3c2fc747a4499349e6944b5791ffacaf5da48 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 28 May 2024 17:09:03 +0100 Subject: [PATCH 11/28] add max length validations, tests --- .../case_form_fields/index.test.tsx | 40 ++-- .../components/configure_cases/flyout.tsx | 82 ++++--- .../components/configure_cases/index.tsx | 218 +++++++++++------- .../connectors/jira/case_fields.tsx | 5 +- .../servicenow_itsm_case_fields.tsx | 2 +- .../public/components/create/assignees.tsx | 3 +- .../public/components/create/category.tsx | 1 - .../public/components/create/connector.tsx | 2 +- .../public/components/create/severity.tsx | 2 +- .../public/components/templates/form.test.tsx | 142 ++++++++---- .../public/components/templates/form.tsx | 1 + .../components/templates/form_fields.test.tsx | 71 +++--- .../public/components/templates/schema.tsx | 14 ++ .../public/components/templates/utils.ts | 2 +- 14 files changed, 333 insertions(+), 252 deletions(-) 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 a650812895c308..852e07edd16798 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 @@ -6,7 +6,7 @@ */ import React from 'react'; -import { act, screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import type { AppMockRenderer } from '../../common/mock'; @@ -159,13 +159,11 @@ describe('CaseFormFields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: 'new', - tags: ['template-1'], - description: 'This is a case description', - title: 'Case with Template 1', - syncAlerts: true, - }, + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + syncAlerts: true, }, true ); @@ -205,15 +203,13 @@ describe('CaseFormFields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: null, - tags: [], - syncAlerts: true, - customFields: { - test_key_1: 'My text test value 1', - test_key_2: false, - test_key_4: false, - }, + category: null, + tags: [], + syncAlerts: true, + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, }, }, true @@ -247,12 +243,10 @@ describe('CaseFormFields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: null, - tags: [], - syncAlerts: true, - assignees: [{ uid: userProfiles[0].uid }], - }, + category: null, + tags: [], + syncAlerts: true, + assignees: [{ uid: userProfiles[0].uid }], }, true ); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 75ad297adae320..8add09fa6bf375 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -19,43 +19,51 @@ import { } from '@elastic/eui'; import type { CustomFieldFormState } from '../custom_fields/form'; import type { TemplateFormState } from '../templates/form'; -import { CustomFieldsForm } from '../custom_fields/form'; -import { TemplateForm } from '../templates/form'; -import type { - ActionConnector, - CustomFieldConfiguration, - TemplateConfiguration, -} from '../../../common/types/domain'; +import type { ActionConnector, CustomFieldConfiguration } from '../../../common/types/domain'; import * as i18n from './translations'; import type { TemplateFormProps } from '../templates/types'; import type { CasesConfigurationUI } from '../../containers/types'; -export interface FlyoutProps { +interface FlyOutBodyProps<T> { + initialValue: T; + onChange: (state: CustomFieldFormState | TemplateFormState) => void; + configConnectors?: ActionConnector[]; + configConnectorId?: string; + configCustomFields?: CasesConfigurationUI['customFields']; +} + +export interface FlyoutProps<T> { disabled: boolean; isLoading: boolean; onCloseFlyout: () => void; - onSaveField: (data: CustomFieldConfiguration | TemplateFormProps | null) => void; - data: CustomFieldConfiguration | TemplateConfiguration | null; - type: 'customField' | 'template'; + onSaveField: (data: T) => void; + data: T; connectors?: ActionConnector[]; configurationConnectorId?: string; configurationCustomFields?: CasesConfigurationUI['customFields']; + renderHeader: () => React.ReactNode; + renderBody: ({ + initialValue, + onChange, + configConnectors, + configConnectorId, + configCustomFields, + }: FlyOutBodyProps<T>) => React.ReactNode; } -const FlyoutComponent: React.FC<FlyoutProps> = ({ +export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormProps | null>({ onCloseFlyout, onSaveField, isLoading, disabled, data: initialValue, - type, + renderHeader, + renderBody, connectors, configurationConnectorId, configurationCustomFields, -}) => { - const dataTestSubj = `${type}Flyout`; - +}: FlyoutProps<T>) => { const [formState, setFormState] = useState<CustomFieldFormState | TemplateFormState>({ isValid: undefined, submit: async () => ({ @@ -70,42 +78,32 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ const { isValid, data } = await submit(); if (isValid) { - onSaveField(data as CustomFieldConfiguration | TemplateFormProps | null); + onSaveField(data as T); } }, [onSaveField, submit]); return ( - <EuiFlyout onClose={onCloseFlyout} data-test-subj={dataTestSubj}> - <EuiFlyoutHeader hasBorder data-test-subj={`${dataTestSubj}Header`}> + <EuiFlyout onClose={onCloseFlyout} data-test-subj="common-flyout"> + <EuiFlyoutHeader hasBorder data-test-subj="flyout-header"> <EuiTitle size="s"> - <h3 id="flyoutTitle"> - {type === 'customField' ? i18n.ADD_CUSTOM_FIELD : i18n.CRATE_TEMPLATE} - </h3> + <h3 id="flyoutTitle">{renderHeader()}</h3> </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - {type === 'customField' ? ( - <CustomFieldsForm - onChange={setFormState} - initialValue={initialValue as CustomFieldConfiguration} - /> - ) : null} - {type === 'template' ? ( - <TemplateForm - onChange={setFormState} - initialValue={initialValue as TemplateFormProps} - connectors={connectors ?? []} - configurationConnectorId={configurationConnectorId ?? ''} - configurationCustomFields={configurationCustomFields ?? []} - /> - ) : null} + {renderBody({ + initialValue, + configConnectors: connectors, + configConnectorId: configurationConnectorId, + configCustomFields: configurationCustomFields, + onChange: setFormState, + })} </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={`${dataTestSubj}Footer`}> + <EuiFlyoutFooter data-test-subj={'flyout-footer'}> <EuiFlexGroup justifyContent="flexStart"> <EuiFlexItem grow={false}> <EuiButtonEmpty onClick={onCloseFlyout} - data-test-subj={`${dataTestSubj}Cancel`} + data-test-subj={'flyout-cancel'} disabled={disabled} isLoading={isLoading} > @@ -117,7 +115,7 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ <EuiButton fill onClick={handleSaveField} - data-test-subj={`${dataTestSubj}Save`} + data-test-subj={'flyout-save'} disabled={disabled} isLoading={isLoading} > @@ -131,6 +129,4 @@ const FlyoutComponent: React.FC<FlyoutProps> = ({ ); }; -FlyoutComponent.displayName = 'CommonFlyout'; - -export const CommonFlyout = React.memo(FlyoutComponent); +CommonFlyout.displayName = 'CommonFlyout'; 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 173f8de6df585c..cfe7eff8083c0b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -49,6 +49,8 @@ import { transformCustomFieldsData } from '../custom_fields/utils'; import { useLicense } from '../../common/use_license'; import { Templates } from '../templates'; import type { TemplateFormProps } from '../templates/types'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; const sectionWrapperCss = css` box-sizing: content-box; @@ -84,12 +86,6 @@ export const ConfigureCases: React.FC = React.memo(() => { const [customFieldToEdit, setCustomFieldToEdit] = useState<CustomFieldConfiguration | null>(null); const [templateToEdit, setTemplateToEdit] = useState<TemplateConfiguration | null>(null); const { euiTheme } = useEuiTheme(); - const flyoutType = - flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible - ? 'customField' - : flyOutVisibility?.type === 'template' && flyOutVisibility?.visible - ? 'template' - : null; const { data: { @@ -331,83 +327,92 @@ export const ConfigureCases: React.FC = React.memo(() => { [setFlyOutVisibility, setCustomFieldToEdit, customFields] ); - const onCloseAddFieldFlyout = useCallback(() => { + const onCloseCustomFieldFlyout = useCallback(() => { setFlyOutVisibility({ type: 'customField', visible: false }); setCustomFieldToEdit(null); }, [setFlyOutVisibility, setCustomFieldToEdit]); - const onFlyoutSave = useCallback( - (data: CustomFieldConfiguration | TemplateFormProps | null | {}) => { - if (flyoutType === 'customField') { - const updatedCustomFields = addOrReplaceField( - customFields, - data as CustomFieldConfiguration - ); - - persistCaseConfigure({ - connector, - customFields: updatedCustomFields as CustomFieldsConfiguration, - templates, - id: configurationId, - version: configurationVersion, - closureType, - }); - - setFlyOutVisibility({ type: 'customField', visible: false }); - setCustomFieldToEdit(null); + const onCustomFieldSave = useCallback( + (data: CustomFieldConfiguration | null) => { + const updatedCustomFields = addOrReplaceField(customFields, data as CustomFieldConfiguration); - return; - } + persistCaseConfigure({ + connector, + customFields: updatedCustomFields as CustomFieldsConfiguration, + templates, + id: configurationId, + version: configurationVersion, + closureType, + }); - if (flyoutType === 'template') { - const { - connectorId, - fields, - customFields: templateCustomFields, - syncAlerts = false, - key, - name, - templateTags, - templateDescription, - ...otherCaseFields - } = data as TemplateFormProps; - - const transformedCustomFields = templateCustomFields - ? transformCustomFieldsData(templateCustomFields, customFields) - : []; - const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; - - const transformedConnector = templateConnector - ? normalizeActionConnector(templateConnector, fields) - : getNoneConnector(); - - const transformedData: TemplateConfiguration = { - key, - name, - description: templateDescription, - tags: templateTags, - caseFields: { - ...otherCaseFields, - connector: transformedConnector, - customFields: transformedCustomFields, - settings: { syncAlerts }, - }, - }; + setFlyOutVisibility({ type: 'customField', visible: false }); + setCustomFieldToEdit(null); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); - const updatedTemplates = addOrReplaceField(templates, transformedData); + const onCloseTemplateFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); + }, [setFlyOutVisibility, setTemplateToEdit]); + + const onTemplateSave = useCallback( + (data: TemplateFormProps | null) => { + const { + connectorId, + fields, + customFields: templateCustomFields, + syncAlerts = false, + key, + name, + templateTags, + templateDescription, + ...otherCaseFields + } = (data ?? {}) as TemplateFormProps; + + const transformedCustomFields = templateCustomFields + ? transformCustomFieldsData(templateCustomFields, customFields) + : []; + const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; + + const transformedConnector = templateConnector + ? normalizeActionConnector(templateConnector, fields) + : getNoneConnector(); + + const transformedData: TemplateConfiguration = { + key, + name, + description: templateDescription, + tags: templateTags, + caseFields: { + ...otherCaseFields, + connector: transformedConnector, + customFields: transformedCustomFields, + settings: { syncAlerts }, + }, + }; + + const updatedTemplates = addOrReplaceField(templates, transformedData); - persistCaseConfigure({ - connector, - customFields, - templates: updatedTemplates, - id: configurationId, - version: configurationVersion, - closureType, - }); + persistCaseConfigure({ + connector, + customFields, + templates: updatedTemplates, + id: configurationId, + version: configurationVersion, + closureType, + }); - setFlyOutVisibility({ type: 'template', visible: false }); - setTemplateToEdit(null); - } + setFlyOutVisibility({ type: 'template', visible: false }); + setTemplateToEdit(null); }, [ closureType, @@ -418,13 +423,12 @@ export const ConfigureCases: React.FC = React.memo(() => { customFields, templates, persistCaseConfigure, - flyoutType, ] ); - const AddOrEditFlyout = - flyOutVisibility !== null && flyoutType !== null ? ( - <CommonFlyout + const AddOrEditCustomFieldFlyout = useMemo(() => { + return flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( + <CommonFlyout<CustomFieldConfiguration> isLoading={loadingCaseConfigure || isPersistingConfiguration} disabled={ !permissions.create || @@ -432,15 +436,56 @@ export const ConfigureCases: React.FC = React.memo(() => { loadingCaseConfigure || isPersistingConfiguration } - type={flyoutType} - data={flyoutType === 'template' ? templateToEdit : customFieldToEdit} - connectors={connectors ?? []} - configurationConnectorId={connector.id} - configurationCustomFields={customFields} - onCloseFlyout={onCloseAddFieldFlyout} - onSaveField={onFlyoutSave} + onCloseFlyout={onCloseCustomFieldFlyout} + onSaveField={onCustomFieldSave} + data={customFieldToEdit as CustomFieldConfiguration} + renderHeader={() => <span>{i18n.ADD_CUSTOM_FIELD}</span>} + renderBody={({ initialValue, onChange }) => ( + <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + )} /> ) : null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flyOutVisibility]); + + const AddOrEditTemplateFlyout = useMemo( + () => + flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( + <CommonFlyout<TemplateFormProps | null> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseTemplateFlyout} + onSaveField={onTemplateSave} + data={templateToEdit as TemplateFormProps | null} + connectors={connectors} + configurationConnectorId={connector.id} + configurationCustomFields={customFields} + renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} + renderBody={({ + initialValue, + configConnectors, + configConnectorId, + configCustomFields, + onChange, + }) => ( + <TemplateForm + initialValue={initialValue} + connectors={configConnectors ?? []} + configurationConnectorId={configConnectorId ?? ''} + configurationCustomFields={configCustomFields ?? []} + onChange={onChange} + /> + )} + /> + ) : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [flyOutVisibility] + ); return ( <EuiPageSection restrictWidth={true}> @@ -532,7 +577,8 @@ export const ConfigureCases: React.FC = React.memo(() => { <EuiSpacer size="xl" /> {ConnectorAddFlyout} {ConnectorEditFlyout} - {AddOrEditFlyout} + {AddOrEditCustomFieldFlyout} + {AddOrEditTemplateFlyout} </div> </EuiPageBody> </EuiPageSection> diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx index fdc8fbc4aa3afb..57772c0b177b7f 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -13,19 +13,18 @@ import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hoo import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { isEmpty } from 'lodash'; +import type { JiraFieldsType } from '../../../../common/types/domain'; import * as i18n from './translations'; import { useKibana } from '../../../common/lib/kibana'; import type { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; -import { JiraFieldsType } from '@kbn/cases-plugin/common/types/domain'; const { emptyField } = fieldValidators; const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ connector }) => { const [{ fields }] = useFormData<{ fields: JiraFieldsType }>(); - const { http } = useKibana().services; const { issueType } = fields ?? {}; @@ -77,7 +76,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co return ( <div data-test-subj={'connector-fields-jira'}> <UseField - path="fields.priority" + path="fields.issueType" component={SelectField} config={{ label: i18n.ISSUE_TYPE, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 968f9fa0583714..7d6981fda05e42 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -13,11 +13,11 @@ import { useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { ServiceNowITSMFieldsType } from '../../../../common/types/domain'; import * as i18n from './translations'; import type { ConnectorFieldsProps } from '../types'; import { useKibana } from '../../../common/lib/kibana'; -import type { ServiceNowITSMFieldsType } from '../../../../common/types/domain'; import { useGetChoices } from './use_get_choices'; import type { Fields } from './types'; import { choicesToEuiOptions } from './helpers'; diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index 5eaaf02275d11b..1e8464dc1a2ed5 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -38,7 +38,6 @@ import { useIsUserTyping } from '../../common/use_is_user_typing'; interface Props { isLoading: boolean; - path?: string; } interface FieldProps { @@ -201,7 +200,7 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( AssigneesFieldComponent.displayName = 'AssigneesFieldComponent'; -const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm, path }) => { +const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/create/category.tsx index 1accf7fad7511b..879a8dfb9bbea4 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/create/category.tsx @@ -12,7 +12,6 @@ import { OptionalFieldLabel } from './optional_field_label'; interface Props { isLoading: boolean; - path?: string; } const CategoryComponent: React.FC<Props> = ({ isLoading }) => { diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 8b4fd877de7e3e..39e04f7bc0be32 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -61,7 +61,7 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingC <EuiFlexGroup> <EuiFlexItem> <UseField - path={'connectorId'} + path="connectorId" config={connectorIdConfig} component={ConnectorSelector} defaultValue={defaultConnectorId} diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx index b42ab4eb3bd922..b65ec7f6a63507 100644 --- a/x-pack/plugins/cases/public/components/create/severity.tsx +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -22,7 +22,7 @@ interface Props { const SeverityComponent: React.FC<Props> = ({ isLoading }) => ( <UseField<CaseSeverity> - path="severity" + path={'severity'} componentProps={{ isLoading, }} 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 012170a29f6421..610741f924823a 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -15,9 +15,13 @@ import { connectorsMock, customFieldsConfigurationMock } from '../../containers/ import userEvent from '@testing-library/user-event'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; -import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_TAG_LENGTH } from '../../../common/constants'; -import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; -import { connect } from 'http2'; +import { + MAX_TAGS_PER_TEMPLATE, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_TAG_LENGTH, +} from '../../../common/constants'; +import { CustomFieldTypes } from '../../../common/types/domain'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -65,8 +69,8 @@ describe('TemplateForm', () => { initialValue: { key: 'template_key_1', name: 'Template 1', - description: 'Sample description', - caseFields: null, + templateDescription: 'Sample description', + templateTags: [], }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -90,11 +94,10 @@ describe('TemplateForm', () => { initialValue: { key: 'template_key_1', name: 'Template 1', - description: 'Sample description', - caseFields: { - title: 'Case with template 1', - description: 'case description', - }, + templateDescription: 'Sample description', + title: 'Case with template 1', + description: 'case description', + templateTags: [], }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -156,13 +159,11 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - description: 'this is a first template', - tags: ['foo', 'bar'], - caseFields: { - connectorId: 'none', - fields: null, - syncAlerts: true, - }, + templateDescription: 'this is a first template', + templateTags: ['foo', 'bar'], + connectorId: 'none', + fields: null, + syncAlerts: true, }); }); }); @@ -209,17 +210,14 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - description: 'this is a first template', - tags: [], - caseFields: { - title: 'Case with Template 1', - description: 'This is a case description', - tags: ['template-1'], - category: 'new', - connectorId: 'none', - fields: null, - syncAlerts: true, - }, + templateDescription: 'this is a first template', + title: 'Case with Template 1', + description: 'This is a case description', + tags: ['template-1'], + category: 'new', + connectorId: 'none', + fields: null, + syncAlerts: true, }); }); }); @@ -264,19 +262,16 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - description: 'this is a first template', - tags: [], - caseFields: { - connectorId: 'servicenow-1', - fields: { - category: 'software', - impact: null, - severity: null, - subcategory: null, - urgency: '1', - }, - syncAlerts: true, + templateDescription: 'this is a first template', + connectorId: 'servicenow-1', + fields: { + category: 'software', + impact: null, + severity: null, + subcategory: null, + urgency: '1', }, + syncAlerts: true, }); }); }); @@ -336,17 +331,14 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - description: 'this is a first template', - tags: [], - caseFields: { - connectorId: 'none', - fields: null, - syncAlerts: true, - customFields: { - test_key_1: 'My text test value 1', - test_key_2: true, - test_key_4: true, - }, + templateDescription: 'this is a first template', + connectorId: 'none', + fields: null, + syncAlerts: true, + customFields: { + test_key_1: 'My text test value 1', + test_key_2: true, + test_key_4: true, }, }); }); @@ -374,6 +366,30 @@ describe('TemplateForm', () => { }); }); + it('shows from state as invalid when template name is too long', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const name = 'a'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), name); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + it('shows from state as invalid when template description missing', async () => { let formState: TemplateFormState; @@ -396,6 +412,30 @@ describe('TemplateForm', () => { }); }); + it('shows from state as invalid when template description is too long', async () => { + let formState: TemplateFormState; + + const onChangeState = (state: TemplateFormState) => (formState = state); + + appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const description = 'a'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), description); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(false); + + expect(data).toEqual({}); + }); + }); + it('shows from state as invalid when template tags are more than 10', async () => { let formState: TemplateFormState; diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index e9a23a50ff5e3c..088c921b2bad1f 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -43,6 +43,7 @@ const FormComponent: React.FC<Props> = ({ key: keyDefaultValue, name: '', templateDescription: '', + templateTags: [], }, options: { stripEmptyFields: false }, schema, 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 e10f01289a3401..7e0c13099df818 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 @@ -156,15 +156,13 @@ describe('form fields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: null, - connectorId: 'none', - tags: [], - syncAlerts: true, - }, + category: null, + connectorId: 'none', + tags: [], + syncAlerts: true, name: 'Template 1', - description: 'this is a first template', - tags: ['first'], + templateDescription: 'this is a first template', + templateTags: ['first'], }, true ); @@ -199,15 +197,13 @@ describe('form fields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: 'new', - tags: ['template-1'], - description: 'This is a case description', - title: 'Case with Template 1', - connectorId: 'none', - syncAlerts: true, - }, - tags: [], + category: 'new', + tags: ['template-1'], + description: 'This is a case description', + title: 'Case with Template 1', + connectorId: 'none', + syncAlerts: true, + templateTags: [], }, true ); @@ -246,18 +242,16 @@ describe('form fields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - category: null, - tags: [], - connectorId: 'none', - customFields: { - test_key_1: 'My text test value 1', - test_key_2: false, - test_key_4: false, - }, - syncAlerts: true, - }, + category: null, tags: [], + connectorId: 'none', + customFields: { + test_key_1: 'My text test value 1', + test_key_2: false, + test_key_4: false, + }, + syncAlerts: true, + templateTags: [], }, true ); @@ -289,18 +283,17 @@ describe('form fields', () => { await waitFor(() => { expect(onSubmit).toBeCalledWith( { - caseFields: { - tags: [], - category: null, - connectorId: 'servicenow-1', - fields: { - category: 'software', - severity: '3', - urgency: '2', - }, - syncAlerts: true, - }, tags: [], + category: null, + connectorId: 'servicenow-1', + fields: { + category: 'software', + severity: '3', + urgency: '2', + subcategory: null, + }, + syncAlerts: true, + templateTags: [], }, true ); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index c4f99cd302fe53..5cbae4db77261e 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -15,6 +15,8 @@ import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_TAG_LENGTH, MAX_TITLE_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, } from '../../../common/constants'; import { OptionalFieldLabel } from '../create/optional_field_label'; import { SEVERITY_TITLE } from '../severity/translations'; @@ -38,6 +40,12 @@ export const schema: FormSchema<TemplateFormProps> = { { validator: emptyField(i18n.REQUIRED_FIELD(i18n.TEMPLATE_NAME)), }, + { + validator: maxLengthField({ + length: MAX_TEMPLATE_NAME_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH), + }), + }, ], }, templateDescription: { @@ -46,6 +54,12 @@ export const schema: FormSchema<TemplateFormProps> = { { validator: emptyField(i18n.REQUIRED_FIELD(i18n.DESCRIPTION)), }, + { + validator: maxLengthField({ + length: MAX_TEMPLATE_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH), + }), + }, ], }, templateTags: { diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 719644e48ab1bb..53712c95a5762d 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -6,7 +6,7 @@ */ import { getConnectorsFormSerializer, isEmptyValue } from '../utils'; -import { ConnectorTypeFields } from '../../../common/types/domain'; +import type { ConnectorTypeFields } from '../../../common/types/domain'; import type { TemplateFormProps } from './types'; export const removeEmptyFields = ( From 03d8f075038a232332c849993da5cb5676b686e1 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 30 May 2024 11:18:14 +0100 Subject: [PATCH 12/28] add utils, flyout and configure case tests --- .../configure_cases/flyout.test.tsx | 356 ++++++++++++++---- .../components/configure_cases/flyout.tsx | 2 +- .../components/configure_cases/index.test.tsx | 110 +++++- .../components/configure_cases/index.tsx | 2 +- .../public/components/templates/form.test.tsx | 14 +- .../public/components/templates/types.ts | 2 +- .../public/components/templates/utils.test.ts | 121 ++++++ .../public/components/templates/utils.ts | 31 +- 8 files changed, 541 insertions(+), 97 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/templates/utils.test.ts 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 3dd90391dd22c1..30c6aaf593ff21 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 @@ -6,21 +6,28 @@ */ import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; +import type { FlyOutBodyProps } from './flyout'; import { CommonFlyout } from './flyout'; -import { customFieldsConfigurationMock } from '../../containers/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, } from '../../../common/constants'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; -import { FIELD_LABEL, DEFAULT_VALUE, REQUIRED_FIELD } from '../custom_fields/translations'; +import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; +import { CustomFieldsForm } from '../custom_fields/form'; +import { TemplateForm } from '../templates/form'; +import type { TemplateFormProps } from '../templates/types'; describe('CommonFlyout ', () => { let appMockRender: AppMockRenderer; @@ -31,7 +38,8 @@ describe('CommonFlyout ', () => { isLoading: false, disabled: false, data: null, - type: 'customField' as const, + renderHeader: () => <div>{`Flyout header`}</div>, + renderBody: () => <div>{`This is a flyout body`}</div>, }; beforeEach(() => { @@ -39,84 +47,123 @@ describe('CommonFlyout ', () => { appMockRender = createAppMockRenderer(); }); - it('renders custom field correctly', async () => { + it('renders flyout correctly', async () => { appMockRender.render(<CommonFlyout {...props} />); - expect(await screen.findByTestId(`${props.type}Flyout`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${props.type}FlyoutHeader`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${props.type}FlyoutCancel`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${props.type}FlyoutSave`)).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-header')).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-cancel')).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-save')).toBeInTheDocument(); }); - it('renders template flyout correctly', async () => { - const newProps = { - ...props, - type: 'template' as const, - }; - appMockRender.render(<CommonFlyout {...newProps} />); + it('renders flyout header correctly', async () => { + appMockRender.render(<CommonFlyout {...props} />); - expect(await screen.findByTestId(`${newProps.type}Flyout`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${newProps.type}FlyoutHeader`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${newProps.type}FlyoutCancel`)).toBeInTheDocument(); - expect(await screen.findByTestId(`${newProps.type}FlyoutSave`)).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-header')).toHaveTextContent('Flyout header'); }); - describe('CustomFieldsFlyout', () => { - it('shows error if field label is too long', async () => { - appMockRender.render(<CommonFlyout {...props} />); + it('renders loading state correctly', async () => { + appMockRender.render(<CommonFlyout {...{ ...props, isLoading: true }} />); - const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + expect(await screen.findAllByRole('progressbar')).toHaveLength(2); + }); - userEvent.type(await screen.findByTestId('custom-field-label-input'), message); + it('renders disable state correctly', async () => { + appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }} />); - expect( - await screen.findByText( - i18n.MAX_LENGTH_ERROR(FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) - ) - ).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-cancel')).toBeDisabled(); + expect(await screen.findByTestId('flyout-save')).toBeDisabled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.click(await screen.findByTestId('flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); }); + }); - it('does not call onSaveField when error', async () => { - appMockRender.render(<CommonFlyout {...props} />); + it('calls onCloseFlyout on close', async () => { + appMockRender.render(<CommonFlyout {...props} />); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); - expect( - await screen.findByText(REQUIRED_FIELD(FIELD_LABEL.toLocaleLowerCase())) - ).toBeInTheDocument(); + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('does not call onSaveField when not valid data', async () => { + appMockRender.render(<CommonFlyout {...props} />); + + userEvent.click(await screen.findByTestId('flyout-save')); + + expect(props.onSaveField).not.toBeCalled(); + }); - expect(props.onSaveField).not.toBeCalled(); + describe('CustomFieldsFlyout', () => { + const renderBody = ({ + initialValue, + onChange, + }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( + <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + ); + + const newProps = { + ...props, + renderBody, + }; + + it('should render custom field form in flyout', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + expect(await screen.findByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(await screen.findByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-required-wrapper')).toBeInTheDocument(); + expect(await screen.findByTestId('text-custom-field-default-value')).toBeInTheDocument(); }); - it('calls onCloseFlyout on cancel', async () => { - appMockRender.render(<CommonFlyout {...props} />); + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutCancel`)); + userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); + expect(newProps.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: CustomFieldTypes.TEXT, + }); }); }); - it('calls onCloseFlyout on close', async () => { - appMockRender.render(<CommonFlyout {...props} />); + it('shows error if field label is too long', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); - userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); - await waitFor(() => { - expect(props.onCloseFlyout).toBeCalled(); - }); + userEvent.type(await screen.findByTestId('custom-field-label-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR(FIELD_LABEL.toLocaleLowerCase(), MAX_CUSTOM_FIELD_LABEL_LENGTH) + ) + ).toBeInTheDocument(); }); describe('Text custom field', () => { it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ + expect(newProps.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: false, @@ -126,17 +173,17 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.paste( await screen.findByTestId('text-custom-field-default-value'), 'Default value' ); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ + expect(newProps.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: false, @@ -147,7 +194,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct params when a custom field is required', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); @@ -155,10 +202,10 @@ describe('CommonFlyout ', () => { await screen.findByTestId('text-custom-field-default-value'), 'Default value' ); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ + expect(newProps.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: true, @@ -169,14 +216,14 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { - expect(props.onSaveField).toBeCalledWith({ + expect(newProps.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: true, @@ -186,10 +233,21 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CommonFlyout {...{ ...props, customField: customFieldsConfigurationMock[0] }} /> + const newRenderBody = ({ + initialValue, + onChange, + }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( + <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> ); + const modifiedProps = { + ...props, + data: customFieldsConfigurationMock[0], + renderBody: newRenderBody, + }; + + appMockRender.render(<CommonFlyout {...modifiedProps} />); + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( 'value', customFieldsConfigurationMock[0].label @@ -203,7 +261,7 @@ describe('CommonFlyout ', () => { }); it('shows an error if default value is too long', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); @@ -222,14 +280,14 @@ describe('CommonFlyout ', () => { describe('Toggle custom field', () => { it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { target: { value: CustomFieldTypes.TOGGLE }, }); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { expect(props.onSaveField).toBeCalledWith({ @@ -243,7 +301,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct default value when a custom field is required', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...newProps} />); fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { target: { value: CustomFieldTypes.TOGGLE }, @@ -251,7 +309,7 @@ describe('CommonFlyout ', () => { userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('toggle-custom-field-required')); - userEvent.click(await screen.findByTestId(`${props.type}FlyoutSave`)); + userEvent.click(await screen.findByTestId('flyout-save')); await waitFor(() => { expect(props.onSaveField).toBeCalledWith({ @@ -265,10 +323,21 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - appMockRender.render( - <CommonFlyout {...{ ...props, customField: customFieldsConfigurationMock[1] }} /> + const newRenderBody = ({ + initialValue, + onChange, + }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( + <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> ); + const modifiedProps = { + ...props, + data: customFieldsConfigurationMock[1], + renderBody: newRenderBody, + }; + + appMockRender.render(<CommonFlyout {...modifiedProps} />); + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( 'value', customFieldsConfigurationMock[1].label @@ -284,4 +353,157 @@ describe('CommonFlyout ', () => { }); }); }); + + describe('TemplateFlyout', () => { + const renderBody = ({ + initialValue, + onChange, + configCustomFields, + configConnectorId, + configConnectors, + }: FlyOutBodyProps<TemplateFormProps | null>) => ( + <TemplateForm + initialValue={initialValue} + connectors={configConnectors ?? []} + configurationConnectorId={configConnectorId ?? 'none'} + configurationCustomFields={configCustomFields ?? []} + onChange={onChange} + /> + ); + + const newProps = { + ...props, + connectors: connectorsMock, + configurationConnectorId: 'none', + configurationCustomFields: [], + renderBody, + }; + + it('should render template form in flyout', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('calls onSaveField form correctly', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + const templateTags = await screen.findByTestId('template-tags'); + userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); + userEvent.keyboard('{enter}'); + + userEvent.click(await screen.findByTestId('flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).toBeCalledWith({ + key: expect.anything(), + name: 'Template name', + templateDescription: 'Template description', + templateTags: ['foo'], + connectorId: 'none', + syncAlerts: true, + }); + }); + }); + + it('calls onSaveField with case fields correctly', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + const caseDescription = await screen.findByTestId('caseDescription'); + userEvent.paste( + within(caseDescription).getByTestId('euiMarkdownEditorTextArea'), + 'This is a case description' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); + + userEvent.click(await screen.findByTestId('flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).toBeCalledWith({ + key: expect.anything(), + name: 'Template name', + templateDescription: 'Template description', + title: 'Case using template', + description: 'This is a case description', + category: 'new', + connectorId: 'none', + syncAlerts: true, + }); + }); + }); + + it('shows error when template name is empty', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + userEvent.click(await screen.findByTestId('flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).not.toHaveBeenCalled(); + }); + + expect(await screen.findByText('A Template name is required.')).toBeInTheDocument(); + }); + + it('shows error if template name is too long', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + const message = 'z'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-name-input'), message); + + expect( + await screen.findByText(i18n.MAX_LENGTH_ERROR('template name', MAX_TEMPLATE_NAME_LENGTH)) + ).toBeInTheDocument(); + }); + + it('shows error when template description is empty', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + + userEvent.click(await screen.findByTestId('flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).not.toHaveBeenCalled(); + }); + + expect(await screen.findByText('A Description is required.')).toBeInTheDocument(); + }); + + it('shows error if template description is too long', async () => { + appMockRender.render(<CommonFlyout {...newProps} />); + + const message = 'z'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + userEvent.paste(await screen.findByTestId('template-description-input'), message); + + expect( + await screen.findByText( + i18n.MAX_LENGTH_ERROR('template description', MAX_TEMPLATE_DESCRIPTION_LENGTH) + ) + ).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 8add09fa6bf375..fd10aa212c0dfd 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -25,7 +25,7 @@ import * as i18n from './translations'; import type { TemplateFormProps } from '../templates/types'; import type { CasesConfigurationUI } from '../../containers/types'; -interface FlyOutBodyProps<T> { +export interface FlyOutBodyProps<T> { initialValue: T; onChange: (state: CustomFieldFormState | TemplateFormState) => void; configConnectors?: ActionConnector[]; 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 fdbac7eb714964..854c10584edb9c 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 @@ -36,6 +36,7 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useLicense } from '../../common/use_license'; +import * as i18n from './translations'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); @@ -732,11 +733,11 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) ); - expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('customFieldFlyoutSave')); + userEvent.click(screen.getByTestId('flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -771,7 +772,7 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); }); it('closes fly out for when click on cancel', async () => { @@ -779,12 +780,12 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('customFieldFlyoutCancel')); + userEvent.click(screen.getByTestId('flyout-cancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('customFieldFlyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); it('closes fly out for when click on save field', async () => { @@ -792,11 +793,11 @@ describe('ConfigureCases', () => { userEvent.click(screen.getByTestId('add-custom-field')); - expect(await screen.findByTestId('customFieldFlyout')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('customFieldFlyoutSave')); + userEvent.click(screen.getByTestId('flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -823,7 +824,7 @@ describe('ConfigureCases', () => { }); expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); - expect(screen.queryByTestId('customFieldFlyout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); }); @@ -839,11 +840,102 @@ describe('ConfigureCases', () => { ...usePersistConfigurationMockResponse, mutate: persistCaseConfigure, })); + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false, isAtLeastGold: () => true }); }); it('should render template section', async () => { appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + }); + + it('should render template form in flyout', async () => { + appMockRender.render(<ConfigureCases />); + + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('flyout-header')).toHaveTextContent(i18n.CRATE_TEMPLATE); + expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); + }); + + it('should add template', async () => { + appMockRender.render(<ConfigureCases />); + expect(await screen.findByTestId('templates-form-group')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('add-template')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'Template description' + ); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); + + userEvent.click(screen.getByTestId('flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: customFieldsConfigurationMock, + templates: [ + { + key: expect.anything(), + name: 'Template name', + description: 'Template description', + tags: [], + caseFields: { + title: 'Case using template', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: customFieldsConfigurationMock[0].type, + value: customFieldsConfigurationMock[0].defaultValue, + }, + { + key: customFieldsConfigurationMock[1].key, + type: customFieldsConfigurationMock[1].type, + value: customFieldsConfigurationMock[1].defaultValue, + }, + { + key: customFieldsConfigurationMock[3].key, + type: customFieldsConfigurationMock[3].type, + value: false, // when no default value for toggle, we set it to false + }, + ], + }, + }, + ], + id: '', + version: '', + }); + }); + + expect(screen.getByTestId('templates-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); }); 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 cfe7eff8083c0b..e993b49478c98b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -391,7 +391,7 @@ export const ConfigureCases: React.FC = React.memo(() => { key, name, description: templateDescription, - tags: templateTags, + tags: templateTags ?? [], caseFields: { ...otherCaseFields, connector: transformedConnector, 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 610741f924823a..519492201d1356 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -26,6 +26,8 @@ import { CustomFieldTypes } from '../../../common/types/domain'; jest.mock('../connectors/servicenow/use_get_choices'); const useGetChoicesMock = useGetChoices as jest.Mock; +const appId = 'securitySolution'; +const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; describe('TemplateForm', () => { let appMockRenderer: AppMockRenderer; @@ -43,6 +45,10 @@ describe('TemplateForm', () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); }); + afterEach(() => { + sessionStorage.removeItem(draftKey); + }); + it('renders correctly', async () => { appMockRenderer.render(<TemplateForm {...defaultProps} />); @@ -70,7 +76,6 @@ describe('TemplateForm', () => { key: 'template_key_1', name: 'Template 1', templateDescription: 'Sample description', - templateTags: [], }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -97,7 +102,6 @@ describe('TemplateForm', () => { templateDescription: 'Sample description', title: 'Case with template 1', description: 'case description', - templateTags: [], }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -162,7 +166,6 @@ describe('TemplateForm', () => { templateDescription: 'this is a first template', templateTags: ['foo', 'bar'], connectorId: 'none', - fields: null, syncAlerts: true, }); }); @@ -216,7 +219,6 @@ describe('TemplateForm', () => { tags: ['template-1'], category: 'new', connectorId: 'none', - fields: null, syncAlerts: true, }); }); @@ -266,9 +268,6 @@ describe('TemplateForm', () => { connectorId: 'servicenow-1', fields: { category: 'software', - impact: null, - severity: null, - subcategory: null, urgency: '1', }, syncAlerts: true, @@ -333,7 +332,6 @@ describe('TemplateForm', () => { name: 'Template 1', templateDescription: 'this is a first template', connectorId: 'none', - fields: null, syncAlerts: true, customFields: { test_key_1: 'My text test value 1', diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index 0c490ac1fac1cf..38fe786e52f57e 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -23,6 +23,6 @@ export type CaseFieldsProps = Omit< export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & CaseFieldsProps & { - templateTags: string[]; + templateTags?: string[]; templateDescription: string; }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts new file mode 100644 index 00000000000000..cfb3def68480c4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { templateSerializer, removeEmptyFields } from './utils'; + +describe('templateSerializer', () => { + it('serializes empty fields correctly', () => { + const res = templateSerializer({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + category: null, + }); + + expect(res).toEqual({}); + }); + + it('serializes non empty fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + templateTags: ['sample'], + category: 'new', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + category: 'new', + templateTags: ['sample'], + }); + }); + + it('serializes custom fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: '', + customFields: { + custom_field_1: 'foobar', + custom_fields_2: '', + custom_field_3: true, + }, + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + customFields: { + custom_field_1: 'foobar', + custom_field_3: true, + }, + }); + }); + + it('serializes connector fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: '', + fields: { + impact: 'high', + severity: 'low', + category: null, + urgency: null, + subcategory: null, + }, + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + fields: { + impact: 'high', + severity: 'low', + }, + }); + }); +}); + +describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 53712c95a5762d..9c6e9f468e8c43 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -6,22 +6,31 @@ */ import { getConnectorsFormSerializer, isEmptyValue } from '../utils'; -import type { ConnectorTypeFields } from '../../../common/types/domain'; import type { TemplateFormProps } from './types'; export const removeEmptyFields = ( - data: Omit<TemplateFormProps, 'fields'> | Record<string, string | boolean> | null | undefined -): Omit<TemplateFormProps, 'fields'> | Record<string, string | boolean> | null => { + data: TemplateFormProps | Record<string, string | boolean | null | undefined> | null | undefined +): TemplateFormProps | Record<string, string | boolean> | null => { if (data) { return Object.entries(data).reduce((acc, [key, value]) => { let initialValue = {}; if (key === 'customFields') { - const nonEmptyFields = removeEmptyFields(value as Record<string, string | boolean>) ?? {}; + const nonEmptyCustomFields = + removeEmptyFields(value as Record<string, string | boolean>) ?? {}; + + if (Object.entries(nonEmptyCustomFields).length > 0) { + initialValue = { + customFields: nonEmptyCustomFields, + }; + } + } else if (key === 'fields') { + const nonEmptyFields = + removeEmptyFields(value as Record<string, string | null | undefined>) ?? {}; if (Object.entries(nonEmptyFields).length > 0) { initialValue = { - customFields: nonEmptyFields, + fields: nonEmptyFields, }; } } else if (!isEmptyValue(value)) { @@ -40,14 +49,16 @@ export const removeEmptyFields = ( export const templateSerializer = <T extends TemplateFormProps | null>(data: T): T => { if (data !== null) { - const { fields, ...rest } = data; - const serializedFields = removeEmptyFields(rest); - const connectorFields = getConnectorsFormSerializer({ fields: fields ?? null }); + const { fields = null, ...rest } = data; + const connectorFields = getConnectorsFormSerializer({ fields }); + const serializedFields = removeEmptyFields({ + ...rest, + fields: connectorFields.fields, + }); return { ...serializedFields, - fields: connectorFields.fields as ConnectorTypeFields['fields'], - }; + } as T; } return data; From 448ea9494db1cbc06ff3cf1f64834ec950e9ef72 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 30 May 2024 12:06:56 +0100 Subject: [PATCH 13/28] add e2e test for templates --- .../configure_cases/flyout.test.tsx | 38 +++++------ .../components/configure_cases/flyout.tsx | 8 +-- .../components/configure_cases/index.test.tsx | 12 ++-- .../components/templates/index.test.tsx | 8 +++ x-pack/test/functional/services/cases/api.ts | 18 ++++++ .../apps/cases/group2/configure.ts | 63 +++++++++++++++++-- .../observability/cases/configure.ts | 6 +- .../security/ftr/cases/configure.ts | 6 +- 8 files changed, 120 insertions(+), 39 deletions(-) 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 30c6aaf593ff21..0768b787049651 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 @@ -51,15 +51,15 @@ describe('CommonFlyout ', () => { appMockRender.render(<CommonFlyout {...props} />); expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - expect(await screen.findByTestId('flyout-header')).toBeInTheDocument(); - expect(await screen.findByTestId('flyout-cancel')).toBeInTheDocument(); - expect(await screen.findByTestId('flyout-save')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-header')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-cancel')).toBeInTheDocument(); + expect(await screen.findByTestId('common-flyout-save')).toBeInTheDocument(); }); it('renders flyout header correctly', async () => { appMockRender.render(<CommonFlyout {...props} />); - expect(await screen.findByTestId('flyout-header')).toHaveTextContent('Flyout header'); + expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent('Flyout header'); }); it('renders loading state correctly', async () => { @@ -71,14 +71,14 @@ describe('CommonFlyout ', () => { it('renders disable state correctly', async () => { appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }} />); - expect(await screen.findByTestId('flyout-cancel')).toBeDisabled(); - expect(await screen.findByTestId('flyout-save')).toBeDisabled(); + expect(await screen.findByTestId('common-flyout-cancel')).toBeDisabled(); + expect(await screen.findByTestId('common-flyout-save')).toBeDisabled(); }); it('calls onCloseFlyout on cancel', async () => { appMockRender.render(<CommonFlyout {...props} />); - userEvent.click(await screen.findByTestId('flyout-cancel')); + userEvent.click(await screen.findByTestId('common-flyout-cancel')); await waitFor(() => { expect(props.onCloseFlyout).toBeCalled(); @@ -98,7 +98,7 @@ describe('CommonFlyout ', () => { it('does not call onSaveField when not valid data', async () => { appMockRender.render(<CommonFlyout {...props} />); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); expect(props.onSaveField).not.toBeCalled(); }); @@ -129,7 +129,7 @@ describe('CommonFlyout ', () => { appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -160,7 +160,7 @@ describe('CommonFlyout ', () => { appMockRender.render(<CommonFlyout {...newProps} />); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -180,7 +180,7 @@ describe('CommonFlyout ', () => { await screen.findByTestId('text-custom-field-default-value'), 'Default value' ); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -202,7 +202,7 @@ describe('CommonFlyout ', () => { await screen.findByTestId('text-custom-field-default-value'), 'Default value' ); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -220,7 +220,7 @@ describe('CommonFlyout ', () => { userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -287,7 +287,7 @@ describe('CommonFlyout ', () => { }); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(props.onSaveField).toBeCalledWith({ @@ -309,7 +309,7 @@ describe('CommonFlyout ', () => { userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('toggle-custom-field-required')); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(props.onSaveField).toBeCalledWith({ @@ -398,7 +398,7 @@ describe('CommonFlyout ', () => { userEvent.paste(within(templateTags).getByRole('combobox'), 'foo'); userEvent.keyboard('{enter}'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -433,7 +433,7 @@ describe('CommonFlyout ', () => { const caseCategory = await screen.findByTestId('caseCategory'); userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ @@ -457,7 +457,7 @@ describe('CommonFlyout ', () => { 'Template description' ); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).not.toHaveBeenCalled(); @@ -483,7 +483,7 @@ describe('CommonFlyout ', () => { userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); - userEvent.click(await screen.findByTestId('flyout-save')); + userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { expect(newProps.onSaveField).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index fd10aa212c0dfd..5bc8d011daa2d7 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -84,7 +84,7 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr return ( <EuiFlyout onClose={onCloseFlyout} data-test-subj="common-flyout"> - <EuiFlyoutHeader hasBorder data-test-subj="flyout-header"> + <EuiFlyoutHeader hasBorder data-test-subj="common-flyout-header"> <EuiTitle size="s"> <h3 id="flyoutTitle">{renderHeader()}</h3> </EuiTitle> @@ -98,12 +98,12 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr onChange: setFormState, })} </EuiFlyoutBody> - <EuiFlyoutFooter data-test-subj={'flyout-footer'}> + <EuiFlyoutFooter data-test-subj={'common-flyout-footer'}> <EuiFlexGroup justifyContent="flexStart"> <EuiFlexItem grow={false}> <EuiButtonEmpty onClick={onCloseFlyout} - data-test-subj={'flyout-cancel'} + data-test-subj={'common-flyout-cancel'} disabled={disabled} isLoading={isLoading} > @@ -115,7 +115,7 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr <EuiButton fill onClick={handleSaveField} - data-test-subj={'flyout-save'} + data-test-subj={'common-flyout-save'} disabled={disabled} isLoading={isLoading} > 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 854c10584edb9c..b6de3c4e69b4bc 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 @@ -737,7 +737,7 @@ describe('ConfigureCases', () => { userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); userEvent.click(screen.getByTestId('text-custom-field-required')); - userEvent.click(screen.getByTestId('flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -782,7 +782,7 @@ describe('ConfigureCases', () => { expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - userEvent.click(screen.getByTestId('flyout-cancel')); + userEvent.click(screen.getByTestId('common-flyout-cancel')); expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); @@ -797,7 +797,7 @@ describe('ConfigureCases', () => { userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); - userEvent.click(screen.getByTestId('flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ @@ -858,7 +858,9 @@ describe('ConfigureCases', () => { userEvent.click(await screen.findByTestId('add-template')); expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); - expect(await screen.findByTestId('flyout-header')).toHaveTextContent(i18n.CRATE_TEMPLATE); + expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent( + i18n.CRATE_TEMPLATE + ); expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); }); @@ -880,7 +882,7 @@ describe('ConfigureCases', () => { const caseTitle = await screen.findByTestId('caseTitle'); userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); - userEvent.click(screen.getByTestId('flyout-save')); + userEvent.click(screen.getByTestId('common-flyout-save')); await waitFor(() => { expect(persistCaseConfigure).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/cases/public/components/templates/index.test.tsx b/x-pack/plugins/cases/public/components/templates/index.test.tsx index 0926db51e97908..2e2c963eb88af2 100644 --- a/x-pack/plugins/cases/public/components/templates/index.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -39,6 +39,14 @@ describe('Templates', () => { expect(await screen.findByTestId('add-template')).toBeInTheDocument(); }); + it('renders empty templates correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: [] }} />); + + expect(await screen.findByTestId('add-template')).toBeInTheDocument(); + expect(await screen.findByTestId('empty-templates')).toBeInTheDocument(); + expect(await screen.queryByTestId('templates-list')).not.toBeInTheDocument(); + }); + it('renders templates correctly', async () => { appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts index 72a65bc98cb61a..7a1d4f52108d19 100644 --- a/x-pack/test/functional/services/cases/api.ts +++ b/x-pack/test/functional/services/cases/api.ts @@ -161,5 +161,23 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { }) ); }, + + async createConfigWithTemplates({ + templates, + owner, + }: { + templates: Configuration['templates']; + owner: string; + }) { + return createConfiguration( + kbnSupertest, + getConfigurationRequest({ + overrides: { + templates, + owner, + }, + }) + ); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index 29eb8c991952ad..2498b8b53f44b3 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { CaseSeverity, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -15,9 +15,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); const header = getPageObject('header'); + const comboBox = getService('comboBox'); const find = getService('find'); - describe('Configure', function () { + describe.only('Configure', function () { before(async () => { await cases.navigation.navigateToConfigurationPage(); }); @@ -81,13 +82,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -105,7 +106,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -126,5 +127,57 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + before(async () => { + await cases.api.createConfigWithTemplates({ + templates: [ + { + key: 'o11y_template', + name: 'My template 1', + description: 'this is my first template', + tags: ['foo'], + caseFields: null, + }, + ], + owner: 'observability', + }); + }); + + it('existing configurations do not interfere', async () => { + // A configuration created in o11y should not be visible in stack + expect(await testSubjects.getVisibleText('empty-templates')).to.be( + 'You do not have any templates yet' + ); + }); + + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + await cases.create.setSeverity(CaseSeverity.HIGH); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await testSubjects.existOrFail('templates-list'); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index 7b56c069fec5df..f2963590b2992a 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -76,13 +76,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -100,7 +100,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index bd36f8f7a8ea1a..7eb66ec1bb6449 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -76,13 +76,13 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Custom fields', function () { it('adds a custom field', async () => { await testSubjects.existOrFail('custom-fields-form-group'); - await common.clickAndValidate('add-custom-field', 'custom-field-flyout'); + await common.clickAndValidate('add-custom-field', 'common-flyout'); await testSubjects.setValue('custom-field-label-input', 'Summary'); await testSubjects.setCheckbox('text-custom-field-required-wrapper', 'check'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); @@ -100,7 +100,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await input.type('!!!'); - await testSubjects.click('custom-field-flyout-save'); + await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); await testSubjects.existOrFail('custom-fields-list'); From db4b1a52d3feedb4b1254e310227380ccfc93ea9 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 30 May 2024 17:34:04 +0100 Subject: [PATCH 14/28] Clean up --- .../configure_cases/flyout.test.tsx | 153 ++++++++++++++++-- .../components/create/sync_alerts_toggle.tsx | 3 +- .../public/components/templates/form.test.tsx | 17 +- .../templates/templates_list.test.tsx | 1 + .../components/templates/templates_list.tsx | 9 +- .../public/components/templates/utils.test.ts | 3 + .../public/components/templates/utils.ts | 17 +- .../use_persist_configuration.test.tsx | 117 +++++++------- .../apps/cases/group2/configure.ts | 2 +- 9 files changed, 227 insertions(+), 95 deletions(-) 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 0768b787049651..5f2c4d22735926 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 @@ -11,8 +11,6 @@ import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import type { FlyOutBodyProps } from './flyout'; -import { CommonFlyout } from './flyout'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH, @@ -22,12 +20,19 @@ import { } from '../../../common/constants'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; - -import * as i18n from './translations'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; import type { TemplateFormProps } from '../templates/types'; +import * as i18n from './translations'; +import type { FlyOutBodyProps } from './flyout'; +import { CommonFlyout } from './flyout'; + +jest.mock('../connectors/servicenow/use_get_choices'); + +const useGetChoicesMock = useGetChoices as jest.Mock; describe('CommonFlyout ', () => { let appMockRender: AppMockRenderer; @@ -413,12 +418,17 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with case fields correctly', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); - - userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); - userEvent.paste( - await screen.findByTestId('template-description-input'), - 'Template description' + appMockRender.render( + <CommonFlyout + {...{ + ...newProps, + data: { + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }, + }} + /> ); const caseTitle = await screen.findByTestId('caseTitle'); @@ -437,9 +447,9 @@ describe('CommonFlyout ', () => { await waitFor(() => { expect(newProps.onSaveField).toBeCalledWith({ - key: expect.anything(), - name: 'Template name', - templateDescription: 'Template description', + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', title: 'Case using template', description: 'This is a case description', category: 'new', @@ -449,6 +459,123 @@ describe('CommonFlyout ', () => { }); }); + it('calls onSaveField form with custom fields correctly', async () => { + const newRenderBody = ({ + initialValue, + onChange, + configCustomFields, + configConnectorId, + configConnectors, + }: FlyOutBodyProps<TemplateFormProps | null>) => ( + <TemplateForm + initialValue={initialValue} + connectors={configConnectors ?? []} + configurationConnectorId={configConnectorId ?? 'none'} + configurationCustomFields={configCustomFields ?? []} + onChange={onChange} + /> + ); + + const modifiedProps = { + ...props, + connectors: [], + configurationConnectorId: 'none', + configurationCustomFields: customFieldsConfigurationMock, + data: { + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }, + renderBody: newRenderBody, + }; + + appMockRender.render(<CommonFlyout {...modifiedProps} />); + + const textCustomField = await screen.findByTestId( + `${customFieldsConfigurationMock[0].key}-text-create-custom-field` + ); + + userEvent.clear(textCustomField); + userEvent.paste(textCustomField, 'this is a sample text!'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + connectorId: 'none', + syncAlerts: true, + customFields: { + [customFieldsConfigurationMock[0].key]: 'this is a sample text!', + [customFieldsConfigurationMock[1].key]: true, + [customFieldsConfigurationMock[3].key]: false, + }, + }); + }); + }); + + it('calls onSaveField form with connector fields correctly', async () => { + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + + const newRenderBody = ({ + initialValue, + onChange, + configCustomFields, + configConnectorId, + configConnectors, + }: FlyOutBodyProps<TemplateFormProps | null>) => ( + <TemplateForm + initialValue={initialValue} + connectors={configConnectors ?? []} + configurationConnectorId={configConnectorId ?? 'none'} + configurationCustomFields={configCustomFields ?? []} + onChange={onChange} + /> + ); + + const modifiedProps = { + ...props, + connectors: connectorsMock, + configurationConnectorId: 'servicenow-1', + configurationCustomFields: [], + data: { + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }, + renderBody: newRenderBody, + }; + + appMockRender.render(<CommonFlyout {...modifiedProps} />); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).toBeCalledWith({ + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + connectorId: 'servicenow-1', + fields: { + category: 'software', + urgency: '1', + impact: null, + severity: null, + subcategory: null, + }, + syncAlerts: true, + }); + }); + }); + it('shows error when template name is empty', async () => { appMockRender.render(<CommonFlyout {...newProps} />); diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx index 15e5da6019558c..5d4e6bb69f5f02 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx @@ -12,10 +12,9 @@ import * as i18n from './translations'; interface Props { isLoading: boolean; - path?: string; } -const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading, path }) => { +const SyncAlertsToggleComponent: React.FC<Props> = ({ isLoading }) => { const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); return ( 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 519492201d1356..acc43a48e4d603 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -7,14 +7,9 @@ import React from 'react'; import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import type { TemplateFormState } from './form'; -import { TemplateForm } from './form'; -import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; -import userEvent from '@testing-library/user-event'; -import { useGetChoices } from '../connectors/servicenow/use_get_choices'; -import { useGetChoicesResponse } from '../create/mock'; import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_DESCRIPTION_LENGTH, @@ -22,6 +17,11 @@ import { MAX_TEMPLATE_TAG_LENGTH, } from '../../../common/constants'; import { CustomFieldTypes } from '../../../common/types/domain'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { useGetChoicesResponse } from '../create/mock'; +import type { TemplateFormState } from './form'; +import { TemplateForm } from './form'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -269,6 +269,9 @@ describe('TemplateForm', () => { fields: { category: 'software', urgency: '1', + impact: null, + severity: null, + subcategory: null, }, syncAlerts: true, }); @@ -342,7 +345,7 @@ describe('TemplateForm', () => { }); }); - it('shows from state as invalid when template name missing', async () => { + it('shows form state as invalid when template name missing', async () => { let formState: TemplateFormState; const onChangeState = (state: TemplateFormState) => (formState = state); diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx index 86094389d94fb2..e96d2b1b0befca 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.test.tsx @@ -55,6 +55,7 @@ describe('TemplatesList', () => { await screen.findByTestId(`template-${templatesConfigurationMock[3].key}`) ).toBeInTheDocument(); expect(await screen.findByText(`${templatesConfigurationMock[3].name}`)).toBeInTheDocument(); + const tags = templatesConfigurationMock[3].tags; tags?.forEach((tag, index) => diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx index c4eec8c618f580..89c4d7f0f8a187 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -16,6 +16,8 @@ import { useEuiTheme, } from '@elastic/eui'; import type { CasesConfigurationUITemplate } from '../../../common/ui'; +import { css } from '@emotion/react'; +import { TruncatedText } from '../truncated_text'; export interface Props { templates: CasesConfigurationUITemplate[]; @@ -42,12 +44,17 @@ const TemplatesListComponent: React.FC<Props> = (props) => { <EuiFlexGroup alignItems="center" gutterSize="s"> <EuiFlexItem grow={false}> <EuiText> - <h4>{template.name}</h4> + <h4> + <TruncatedText text={template.name} /> + </h4> </EuiText> </EuiFlexItem> {template.tags?.length ? template.tags.map((tag, index) => ( <EuiBadge + css={css` + max-width: 100px; + `} key={`${template.key}-tag-${index}`} data-test-subj={`${template.key}-tag-${index}`} color={euiTheme.colors.body} diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts index cfb3def68480c4..407c812dcce810 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -84,6 +84,9 @@ describe('templateSerializer', () => { fields: { impact: 'high', severity: 'low', + category: null, + urgency: null, + subcategory: null, }, }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 9c6e9f468e8c43..cfcb9586eea7c8 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -17,22 +17,13 @@ export const removeEmptyFields = ( if (key === 'customFields') { const nonEmptyCustomFields = - removeEmptyFields(value as Record<string, string | boolean>) ?? {}; + removeEmptyFields(value as Record<string, string | boolean | null | undefined>) ?? {}; if (Object.entries(nonEmptyCustomFields).length > 0) { initialValue = { customFields: nonEmptyCustomFields, }; } - } else if (key === 'fields') { - const nonEmptyFields = - removeEmptyFields(value as Record<string, string | null | undefined>) ?? {}; - - if (Object.entries(nonEmptyFields).length > 0) { - initialValue = { - fields: nonEmptyFields, - }; - } } else if (!isEmptyValue(value)) { initialValue = { [key]: value }; } @@ -51,13 +42,11 @@ export const templateSerializer = <T extends TemplateFormProps | null>(data: T): if (data !== null) { const { fields = null, ...rest } = data; const connectorFields = getConnectorsFormSerializer({ fields }); - const serializedFields = removeEmptyFields({ - ...rest, - fields: connectorFields.fields, - }); + const serializedFields = removeEmptyFields({ ...rest, fields: connectorFields.fields }); return { ...serializedFields, + // fields: connectorFields.fields, } as T; } diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx index c1839bfbac6819..4fab35fd5ce5fb 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -55,7 +55,7 @@ describe('usePersistConfiguration', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -63,23 +63,24 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...request, version: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + templates: [], + }); + }); expect(spyPatch).not.toHaveBeenCalled(); - expect(spyPost).toHaveBeenCalledWith({ - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - owner: 'securitySolution', - templates: [], - }); }); it('calls postCaseConfigure when the version is empty', async () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -87,22 +88,23 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...request, id: 'test' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + owner: 'securitySolution', + }); + }); expect(spyPatch).not.toHaveBeenCalled(); - expect(spyPost).toHaveBeenCalledWith({ - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - templates: [], - owner: 'securitySolution', - }); }); it('calls postCaseConfigure with correct data', async () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -116,14 +118,14 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...newRequest, id: 'test-id' }); }); - await waitForNextUpdate(); - - expect(spyPost).toHaveBeenCalledWith({ - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: customFieldsConfigurationMock, - templates: templatesConfigurationMock, - owner: 'securitySolution', + await waitFor(() => { + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + owner: 'securitySolution', + }); }); }); @@ -131,7 +133,7 @@ describe('usePersistConfiguration', () => { const spyPost = jest.spyOn(api, 'postCaseConfigure'); const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -139,22 +141,23 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...request, id: 'test-id', version: 'test-version' }); }); - await waitForNextUpdate(); + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + templates: [], + version: 'test-version', + }); + }); expect(spyPost).not.toHaveBeenCalled(); - expect(spyPatch).toHaveBeenCalledWith('test-id', { - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: [], - templates: [], - version: 'test-version', - }); }); it('calls patchCaseConfigure with correct data', async () => { const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -168,20 +171,20 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); }); - await waitForNextUpdate(); - - expect(spyPatch).toHaveBeenCalledWith('test-id', { - closure_type: 'close-by-user', - connector: { fields: null, id: 'none', name: 'none', type: '.none' }, - customFields: customFieldsConfigurationMock, - templates: templatesConfigurationMock, - version: 'test-version', + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + version: 'test-version', + }); }); }); it('invalidates the queries correctly', async () => { const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -189,13 +192,13 @@ describe('usePersistConfiguration', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration({})); + }); }); it('shows the success toaster', async () => { - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -203,9 +206,9 @@ describe('usePersistConfiguration', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addSuccess).toHaveBeenCalled(); + await waitFor(() => { + expect(addSuccess).toHaveBeenCalled(); + }); }); it('shows a toast error when the api return an error', async () => { @@ -213,7 +216,7 @@ describe('usePersistConfiguration', () => { .spyOn(api, 'postCaseConfigure') .mockRejectedValue(new Error('useCreateAttachments: Test error')); - const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + const { waitFor, result } = renderHook(() => usePersistConfiguration(), { wrapper: appMockRender.AppWrapper, }); @@ -221,8 +224,8 @@ describe('usePersistConfiguration', () => { result.current.mutate(request); }); - await waitForNextUpdate(); - - expect(addError).toHaveBeenCalled(); + await waitFor(() => { + expect(addError).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index 2498b8b53f44b3..d3c909595006b0 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -18,7 +18,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const comboBox = getService('comboBox'); const find = getService('find'); - describe.only('Configure', function () { + describe('Configure', function () { before(async () => { await cases.navigation.navigateToConfigurationPage(); }); From 83f3a6d5c102e6b1831999a401cb7bc2a5a0a4bc Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 30 May 2024 17:56:18 +0100 Subject: [PATCH 15/28] set autoFocus --- .../public/components/case_form_fields/custom_fields.tsx | 2 -- x-pack/plugins/cases/public/components/create/form.tsx | 2 +- x-pack/plugins/cases/public/components/create/title.tsx | 6 +++--- .../plugins/cases/public/components/templates/connector.tsx | 2 -- .../cases/public/components/templates/form_fields.tsx | 1 - .../cases/public/components/templates/templates_list.tsx | 2 +- 6 files changed, 5 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx index e430be8be8efad..56354119b01bf6 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -15,14 +15,12 @@ import * as i18n from './translations'; interface Props { isLoading: boolean; - path?: string; setAsOptional?: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; } const CustomFieldsComponent: React.FC<Props> = ({ isLoading, - path, setAsOptional, configurationCustomFields, }) => { diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 4c95b6e11a11a3..8e860527ec6582 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -91,7 +91,7 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m title: i18n.STEP_ONE_TITLE, children: ( <> - <Title isLoading={isSubmitting} /> + <Title isLoading={isSubmitting} autoFocus={true} /> {caseAssignmentAuthorized ? ( <div css={containerCss(euiTheme)}> <Assignees isLoading={isSubmitting} /> diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx index 3e309b6e91e703..60e0ec529a65d9 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -12,17 +12,17 @@ const CommonUseField = getUseField({ component: Field }); interface Props { isLoading: boolean; - path?: string; + autoFocus?: boolean; } -const TitleComponent: React.FC<Props> = ({ isLoading, path }) => ( +const TitleComponent: React.FC<Props> = ({ isLoading, autoFocus = false }) => ( <CommonUseField path="title" componentProps={{ idAria: 'caseTitle', 'data-test-subj': 'caseTitle', euiFieldProps: { - autoFocus: true, + autoFocus: autoFocus, fullWidth: true, disabled: isLoading, }, diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 1edcd60c32e9ac..2886da2663333d 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -22,14 +22,12 @@ import { schema } from './schema'; interface Props { connectors: ActionConnector[]; isLoading: boolean; - path?: string; configurationConnectorId: string; } const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, - path, configurationConnectorId, }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 9947b68292eb66..1adf2852de071d 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -63,7 +63,6 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ euiFieldProps: { 'data-test-subj': 'template-description-input', fullWidth: true, - autoFocus: true, isLoading: isSubmitting, }, }} diff --git a/x-pack/plugins/cases/public/components/templates/templates_list.tsx b/x-pack/plugins/cases/public/components/templates/templates_list.tsx index 89c4d7f0f8a187..999b02edf32a15 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -15,8 +15,8 @@ import { EuiBadge, useEuiTheme, } from '@elastic/eui'; -import type { CasesConfigurationUITemplate } from '../../../common/ui'; import { css } from '@emotion/react'; +import type { CasesConfigurationUITemplate } from '../../../common/ui'; import { TruncatedText } from '../truncated_text'; export interface Props { From 1999ca099079b898c4d410e2d5a032975ee3a837 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 30 May 2024 18:01:44 +0100 Subject: [PATCH 16/28] remove caseFields path --- .../plugins/cases/public/components/templates/connector.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/templates/connector.test.tsx b/x-pack/plugins/cases/public/components/templates/connector.test.tsx index b286e98fa185ae..cc053f52a34f13 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.test.tsx @@ -47,7 +47,6 @@ const defaultProps = { connectors: connectorsMock, isLoading: false, configurationConnectorId: 'none', - path: 'caseFields.connectorId', }; describe('Connector', () => { From 49220d926fc6bb470eaadf7d69c064ce0b457967 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 31 May 2024 09:17:46 +0100 Subject: [PATCH 17/28] add e2e tests to serverless --- .../observability/cases/configure.ts | 54 ++++++++++++++++++ .../security/ftr/cases/configure.ts | 55 ++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index f2963590b2992a..0e670e88a9b3df 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CaseSeverity } from '@kbn/cases-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -20,6 +21,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { @@ -121,5 +123,57 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + before(async () => { + await cases.api.createConfigWithTemplates({ + templates: [ + { + key: 'o11y_template', + name: 'My template 1', + description: 'this is my first template', + tags: ['foo'], + caseFields: null, + }, + ], + owner: 'observability', + }); + }); + + it('existing configurations do not interfere', async () => { + // A configuration created in o11y should not be visible in stack + expect(await testSubjects.getVisibleText('empty-templates')).to.be( + 'You do not have any templates yet' + ); + }); + + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + await cases.create.setSeverity(CaseSeverity.HIGH); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await testSubjects.existOrFail('templates-list'); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + }); }); }; diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index 7eb66ec1bb6449..43267af84682ca 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { CaseSeverity, SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; import { navigateToCasesApp } from '../../../../../shared/lib/cases/helpers'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -22,6 +22,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); const retry = getService('retry'); const find = getService('find'); + const comboBox = getService('comboBox'); describe('Configure Case', function () { before(async () => { @@ -121,5 +122,57 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.missingOrFail('custom-fields-list'); }); }); + + describe('Templates', function () { + before(async () => { + await cases.api.createConfigWithTemplates({ + templates: [ + { + key: 'o11y_template', + name: 'My template 1', + description: 'this is my first template', + tags: ['foo'], + caseFields: null, + }, + ], + owner: 'observability', + }); + }); + + it('existing configurations do not interfere', async () => { + // A configuration created in o11y should not be visible in stack + expect(await testSubjects.getVisibleText('empty-templates')).to.be( + 'You do not have any templates yet' + ); + }); + + it('adds a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + await common.clickAndValidate('add-template', 'common-flyout'); + + await testSubjects.setValue('template-name-input', 'Template name'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('case with template'); + + await cases.create.setDescription('test description'); + + await cases.create.setTags('tagme'); + await cases.create.setCategory('new'); + await cases.create.setSeverity(CaseSeverity.HIGH); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await testSubjects.existOrFail('templates-list'); + + expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); + }); + }); }); }; From 75f24e29a5700d5ccf3ef523eca3b33cf0982edf Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 31 May 2024 10:15:05 +0100 Subject: [PATCH 18/28] fix type error --- x-pack/plugins/cases/public/components/templates/form_fields.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 1adf2852de071d..265f6d8d3d2ca3 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -96,7 +96,6 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ connectors={connectors} isLoading={isSubmitting} configurationConnectorId={configurationConnectorId} - path="connectorId" /> </div> ), From 82cd6af1d5b9059faedddea6af555d26833d4dd5 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 31 May 2024 11:07:51 +0100 Subject: [PATCH 19/28] lint fix title --- x-pack/plugins/cases/public/components/create/title.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/create/title.tsx index 60e0ec529a65d9..8727a3cc019646 100644 --- a/x-pack/plugins/cases/public/components/create/title.tsx +++ b/x-pack/plugins/cases/public/components/create/title.tsx @@ -22,7 +22,7 @@ const TitleComponent: React.FC<Props> = ({ isLoading, autoFocus = false }) => ( idAria: 'caseTitle', 'data-test-subj': 'caseTitle', euiFieldProps: { - autoFocus: autoFocus, + autoFocus, fullWidth: true, disabled: isLoading, }, From a468d94b491930d3aa174615abc6b77d0d07c375 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 31 May 2024 16:29:42 +0100 Subject: [PATCH 20/28] use description text area, remove session storage key for description, make description optional, show template tags options --- .../common/types/api/configure/v1.test.ts | 12 +++ .../cases/common/types/api/configure/v1.ts | 16 ++-- .../common/types/domain/configure/v1.test.ts | 2 - .../cases/common/types/domain/configure/v1.ts | 8 +- .../case_form_fields/index.test.tsx | 9 -- .../components/case_form_fields/index.tsx | 11 +-- .../configure_cases/flyout.test.tsx | 23 ++--- .../components/configure_cases/flyout.tsx | 5 ++ .../components/configure_cases/index.tsx | 6 ++ .../public/components/create/description.tsx | 2 +- .../cases/public/components/create/tags.tsx | 10 +-- .../components/markdown_editor/eui_form.tsx | 4 +- .../use_markdown_session_storage.test.tsx | 10 +++ .../use_markdown_session_storage.tsx | 2 +- .../public/components/templates/form.test.tsx | 29 +----- .../public/components/templates/form.tsx | 3 + .../components/templates/form_fields.test.tsx | 7 +- .../components/templates/form_fields.tsx | 28 +++--- .../public/components/templates/schema.tsx | 3 - .../templates/template_tags.test.tsx | 88 +++++++++++++++++++ .../components/templates/template_tags.tsx | 47 ++++++++++ .../server/client/configure/client.test.ts | 2 - .../cases/server/common/types/configure.ts | 2 +- 23 files changed, 219 insertions(+), 110 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/templates/template_tags.test.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/template_tags.tsx diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index 1932398507dda0..c16dfbc60eaf70 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -585,6 +585,18 @@ describe('configure', () => { }); }); + it('does not throw when there is no description or tags', () => { + const newRequest = { + key: 'template_key_1', + name: 'Template 1', + caseFields: null, + }; + + expect(PathReporter.report(TemplateConfigurationRt.decode({ ...newRequest }))).toContain( + 'No errors!' + ); + }); + it('limits name to 50 characters', () => { const longName = 'x'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); 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 b8973f1ca9ab25..bd2e1f5c11af0f 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -85,14 +85,6 @@ export const TemplateConfigurationRt = rt.intersection([ * name of template */ name: limitedStringSchema({ fieldName: 'name', min: 1, max: MAX_TEMPLATE_NAME_LENGTH }), - /** - * description of templates - */ - description: limitedStringSchema({ - fieldName: 'description', - min: 1, - max: MAX_TEMPLATE_DESCRIPTION_LENGTH, - }), /** * case fields */ @@ -100,6 +92,14 @@ export const TemplateConfigurationRt = rt.intersection([ }), rt.exact( rt.partial({ + /** + * description of templates + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 0, + max: MAX_TEMPLATE_DESCRIPTION_LENGTH, + }), /** * tags of templates */ diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index bab00c9c2fa2ad..13637fb4d8c686 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -81,7 +81,6 @@ describe('configure', () => { const templateWithFewCaseFields = { key: 'template_sample_2', name: 'Sample template 2', - description: 'this is second sample template', tags: [], caseFields: { title: 'Case with sample template 2', @@ -92,7 +91,6 @@ describe('configure', () => { const templateWithNoCaseFields = { key: 'template_sample_3', name: 'Sample template 3', - description: 'this is third sample template', caseFields: null, }; 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 7a516d68ccea6e..1e4e30c95e381c 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -68,10 +68,6 @@ export const TemplateConfigurationRt = rt.intersection([ * name of template */ name: rt.string, - /** - * description of template - */ - description: rt.string, /** * case fields of template */ @@ -79,6 +75,10 @@ export const TemplateConfigurationRt = rt.intersection([ }), rt.exact( rt.partial({ + /** + * description of template + */ + description: rt.string, /** * tags of template */ 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 852e07edd16798..ad0d6b54d35114 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 @@ -21,16 +21,12 @@ import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; jest.mock('../../containers/user_profiles/api'); -const appId = 'securitySolution'; -const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; - describe('CaseFormFields', () => { let appMock: AppMockRenderer; const onSubmit = jest.fn(); const defaultProps = { isLoading: false, configurationCustomFields: [], - draftStorageKey: '', }; beforeEach(() => { @@ -38,10 +34,6 @@ describe('CaseFormFields', () => { jest.clearAllMocks(); }); - afterEach(() => { - sessionStorage.removeItem(draftKey); - }); - it('renders correctly', async () => { appMock.render( <FormTestComponent onSubmit={onSubmit}> @@ -83,7 +75,6 @@ describe('CaseFormFields', () => { <CaseFormFields isLoading={false} configurationCustomFields={customFieldsConfigurationMock} - draftStorageKey="" /> </FormTestComponent> ); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index 13a0dd959605c5..4b99b21c1e783a 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -21,27 +21,24 @@ import type { CasesConfigurationUI } from '../../containers/types'; interface Props { isLoading: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; - draftStorageKey: string; } -const CaseFormFieldsComponent: React.FC<Props> = ({ - isLoading, - configurationCustomFields, - draftStorageKey, -}) => { +const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading, configurationCustomFields }) => { const { caseAssignmentAuthorized, isSyncAlertsEnabled } = useCasesFeatures(); return ( <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> <Title isLoading={isLoading} /> + {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} + <Tags isLoading={isLoading} /> <Category isLoading={isLoading} /> <Severity isLoading={isLoading} /> - <Description isLoading={isLoading} draftStorageKey={draftStorageKey} /> + <Description isLoading={isLoading} /> {isSyncAlertsEnabled ? <SyncAlertsToggle isLoading={isLoading} /> : null} 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 5f2c4d22735926..f104a18fed10c5 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 @@ -366,12 +366,14 @@ describe('CommonFlyout ', () => { configCustomFields, configConnectorId, configConnectors, + configTemplateTags, }: FlyOutBodyProps<TemplateFormProps | null>) => ( <TemplateForm initialValue={initialValue} connectors={configConnectors ?? []} configurationConnectorId={configConnectorId ?? 'none'} configurationCustomFields={configCustomFields ?? []} + configurationTemplateTags={configTemplateTags ?? []} onChange={onChange} /> ); @@ -381,6 +383,7 @@ describe('CommonFlyout ', () => { connectors: connectorsMock, configurationConnectorId: 'none', configurationCustomFields: [], + configurationTemplateTags: [], renderBody, }; @@ -466,12 +469,14 @@ describe('CommonFlyout ', () => { configCustomFields, configConnectorId, configConnectors, + configTemplateTags, }: FlyOutBodyProps<TemplateFormProps | null>) => ( <TemplateForm initialValue={initialValue} connectors={configConnectors ?? []} configurationConnectorId={configConnectorId ?? 'none'} configurationCustomFields={configCustomFields ?? []} + configurationTemplateTags={configTemplateTags ?? []} onChange={onChange} /> ); @@ -481,6 +486,7 @@ describe('CommonFlyout ', () => { connectors: [], configurationConnectorId: 'none', configurationCustomFields: customFieldsConfigurationMock, + configurationTemplateTags: [], data: { key: 'random_key', name: 'Template 1', @@ -525,12 +531,14 @@ describe('CommonFlyout ', () => { configCustomFields, configConnectorId, configConnectors, + configTemplateTags, }: FlyOutBodyProps<TemplateFormProps | null>) => ( <TemplateForm initialValue={initialValue} connectors={configConnectors ?? []} configurationConnectorId={configConnectorId ?? 'none'} configurationCustomFields={configCustomFields ?? []} + configurationTemplateTags={configTemplateTags ?? []} onChange={onChange} /> ); @@ -540,6 +548,7 @@ describe('CommonFlyout ', () => { connectors: connectorsMock, configurationConnectorId: 'servicenow-1', configurationCustomFields: [], + configurationTemplateTags: [], data: { key: 'random_key', name: 'Template 1', @@ -605,20 +614,6 @@ describe('CommonFlyout ', () => { ).toBeInTheDocument(); }); - it('shows error when template description is empty', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); - - userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); - - userEvent.click(await screen.findByTestId('common-flyout-save')); - - await waitFor(() => { - expect(newProps.onSaveField).not.toHaveBeenCalled(); - }); - - expect(await screen.findByText('A Description is required.')).toBeInTheDocument(); - }); - it('shows error if template description is too long', async () => { appMockRender.render(<CommonFlyout {...newProps} />); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 5bc8d011daa2d7..b75a7034d893ef 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -31,6 +31,7 @@ export interface FlyOutBodyProps<T> { configConnectors?: ActionConnector[]; configConnectorId?: string; configCustomFields?: CasesConfigurationUI['customFields']; + configTemplateTags?: string[]; } export interface FlyoutProps<T> { @@ -42,6 +43,7 @@ export interface FlyoutProps<T> { connectors?: ActionConnector[]; configurationConnectorId?: string; configurationCustomFields?: CasesConfigurationUI['customFields']; + configurationTemplateTags?: string[]; renderHeader: () => React.ReactNode; renderBody: ({ initialValue, @@ -49,6 +51,7 @@ export interface FlyoutProps<T> { configConnectors, configConnectorId, configCustomFields, + configTemplateTags, }: FlyOutBodyProps<T>) => React.ReactNode; } @@ -63,6 +66,7 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr connectors, configurationConnectorId, configurationCustomFields, + configurationTemplateTags, }: FlyoutProps<T>) => { const [formState, setFormState] = useState<CustomFieldFormState | TemplateFormState>({ isValid: undefined, @@ -95,6 +99,7 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr configConnectors: connectors, configConnectorId: configurationConnectorId, configCustomFields: configurationCustomFields, + configTemplateTags: configurationTemplateTags, onChange: setFormState, })} </EuiFlyoutBody> 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 e993b49478c98b..6891e3de1cb889 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -108,6 +108,9 @@ export const ConfigureCases: React.FC = React.memo(() => { } = usePersistConfiguration(); const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; + const configurationTemplateTags = templates + .map((template) => (template?.tags?.length ? template.tags : [])) + .flat(); const { isLoading: isLoadingConnectors, @@ -465,12 +468,14 @@ export const ConfigureCases: React.FC = React.memo(() => { connectors={connectors} configurationConnectorId={connector.id} configurationCustomFields={customFields} + configurationTemplateTags={configurationTemplateTags} renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} renderBody={({ initialValue, configConnectors, configConnectorId, configCustomFields, + configTemplateTags, onChange, }) => ( <TemplateForm @@ -478,6 +483,7 @@ export const ConfigureCases: React.FC = React.memo(() => { connectors={configConnectors ?? []} configurationConnectorId={configConnectorId ?? ''} configurationCustomFields={configCustomFields ?? []} + configurationTemplateTags={configTemplateTags ?? []} onChange={onChange} /> )} diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 5c512e701c123b..881ea13c19c3db 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -12,7 +12,7 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; - draftStorageKey: string; + draftStorageKey?: string; } export const fieldName = 'description'; diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index 211a7d3219b7b1..e08f9ace8dde06 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -13,11 +13,9 @@ import { useGetTags } from '../../containers/use_get_tags'; import * as i18n from './translations'; interface Props { isLoading: boolean; - path?: string; - dataTestSubject?: string; } -const TagsComponent: React.FC<Props> = ({ isLoading, path, dataTestSubject }) => { +const TagsComponent: React.FC<Props> = ({ isLoading }) => { const { data: tagOptions = [], isLoading: isLoadingTags } = useGetTags(); const options = useMemo( () => @@ -29,12 +27,12 @@ const TagsComponent: React.FC<Props> = ({ isLoading, path, dataTestSubject }) => return ( <UseField - path={path ?? 'tags'} + path="tags" component={ComboBoxField} defaultValue={[]} componentProps={{ - idAria: dataTestSubject ?? 'caseTags', - 'data-test-subj': dataTestSubject ?? 'caseTags', + idAria: 'caseTags', + 'data-test-subj': 'caseTags', euiFieldProps: { fullWidth: true, placeholder: '', diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index c939feda42e408..b1437e2e2a2539 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -34,7 +34,7 @@ type MarkdownEditorFormProps = EuiMarkdownEditorProps & { bottomRightContent?: React.ReactNode; caseTitle?: string; caseTags?: string[]; - draftStorageKey: string; + draftStorageKey?: string; disabledUiPlugins?: string[]; initialValue?: string; }; @@ -59,7 +59,7 @@ export const MarkdownEditorForm = React.memo( const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const { hasConflicts } = useMarkdownSessionStorage({ field, - sessionKey: draftStorageKey, + sessionKey: draftStorageKey ?? '', initialValue, }); const { euiTheme } = useEuiTheme(); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx index 7de2e83cf234d1..9ba64701189a86 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx @@ -54,6 +54,16 @@ describe('useMarkdownSessionStorage', () => { }); }); + it('should return hasConflicts as false when sessionKey is empty', async () => { + const { result, waitFor } = renderHook(() => + useMarkdownSessionStorage({ field, sessionKey: '', initialValue }) + ); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + }); + }); + it('should update the session value with field value when it is first render', async () => { const { waitFor } = renderHook<SessionStorageType, { hasConflicts: boolean }>( (props) => { diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx index e33fed67298585..4505802181c426 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx @@ -30,7 +30,7 @@ export const useMarkdownSessionStorage = ({ const [sessionValue, setSessionValue] = useSessionStorage(sessionKey, '', true); - if (!isEmpty(sessionValue) && isFirstRender.current) { + if (!isEmpty(sessionValue) && !isEmpty(sessionKey) && isFirstRender.current) { field.setValue(sessionValue); } 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 acc43a48e4d603..c8e55019b67a68 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -26,8 +26,6 @@ import { TemplateForm } from './form'; jest.mock('../connectors/servicenow/use_get_choices'); const useGetChoicesMock = useGetChoices as jest.Mock; -const appId = 'securitySolution'; -const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; describe('TemplateForm', () => { let appMockRenderer: AppMockRenderer; @@ -35,6 +33,7 @@ describe('TemplateForm', () => { connectors: connectorsMock, configurationConnectorId: 'none', configurationCustomFields: [], + configurationTemplateTags: [], onChange: jest.fn(), initialValue: null, }; @@ -45,10 +44,6 @@ describe('TemplateForm', () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); }); - afterEach(() => { - sessionStorage.removeItem(draftKey); - }); - it('renders correctly', async () => { appMockRenderer.render(<TemplateForm {...defaultProps} />); @@ -391,28 +386,6 @@ describe('TemplateForm', () => { }); }); - it('shows from state as invalid when template description missing', async () => { - let formState: TemplateFormState; - - const onChangeState = (state: TemplateFormState) => (formState = state); - - appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); - - await waitFor(() => { - expect(formState).not.toBeUndefined(); - }); - - userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); - - await act(async () => { - const { data, isValid } = await formState!.submit(); - - expect(isValid).toBe(false); - - expect(data).toEqual({}); - }); - }); - it('shows from state as invalid when template description is too long', async () => { let formState: TemplateFormState; diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 088c921b2bad1f..74ea0649a65833 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -27,6 +27,7 @@ interface Props { connectors: ActionConnector[]; configurationConnectorId: string; configurationCustomFields: CasesConfigurationUI['customFields']; + configurationTemplateTags: string[]; } const FormComponent: React.FC<Props> = ({ @@ -35,6 +36,7 @@ const FormComponent: React.FC<Props> = ({ connectors, configurationConnectorId, configurationCustomFields, + configurationTemplateTags, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -65,6 +67,7 @@ const FormComponent: React.FC<Props> = ({ connectors={connectors} configurationConnectorId={configurationConnectorId} configurationCustomFields={configurationCustomFields} + configurationTemplateTags={configurationTemplateTags} /> </Form> ); 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 7e0c13099df818..30f899578fa339 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 @@ -28,9 +28,8 @@ describe('form fields', () => { connectors: connectorsMock, configurationConnectorId: 'none', configurationCustomFields: [], + configurationTemplateTags: [], }; - const appId = 'securitySolution'; - const draftKey = `cases.${appId}.createCaseTemplate.description.markdownEditor`; beforeEach(() => { jest.clearAllMocks(); @@ -38,10 +37,6 @@ describe('form fields', () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); }); - afterEach(() => { - sessionStorage.removeItem(draftKey); - }); - it('renders correctly', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 265f6d8d3d2ca3..95ca2041e6b312 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -7,22 +7,25 @@ import React, { memo, useMemo } from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { TextField, HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { + TextField, + HiddenField, + TextAreaField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiSteps } from '@elastic/eui'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; import { Connector } from './connector'; import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { Tags } from '../create/tags'; +import { TemplateTags } from './template_tags'; interface FormFieldsProps { isSubmitting?: boolean; connectors: ActionConnector[]; configurationConnectorId: string; configurationCustomFields: CasesConfigurationUI['customFields']; + configurationTemplateTags: string[]; } const FormFieldsComponent: React.FC<FormFieldsProps> = ({ @@ -30,14 +33,8 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ connectors, configurationConnectorId, configurationCustomFields, + configurationTemplateTags, }) => { - const { owner } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey({ - appId: owner[0], - caseId: 'createCaseTemplate', - commentId: 'description', - }); - const firstStep = useMemo( () => ({ title: i18n.TEMPLATE_FIELDS, @@ -55,10 +52,10 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ }, }} /> - <Tags isLoading={isSubmitting} path="templateTags" dataTestSubject="template-tags" /> + <TemplateTags isLoading={isSubmitting} tags={configurationTemplateTags} /> <UseField path="templateDescription" - component={TextField} + component={TextAreaField} componentProps={{ euiFieldProps: { 'data-test-subj': 'template-description-input', @@ -70,7 +67,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ </> ), }), - [isSubmitting] + [isSubmitting, configurationTemplateTags] ); const secondStep = useMemo( @@ -79,12 +76,11 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ children: ( <CaseFormFields configurationCustomFields={configurationCustomFields} - draftStorageKey={draftStorageKey} isLoading={isSubmitting} /> ), }), - [isSubmitting, configurationCustomFields, draftStorageKey] + [isSubmitting, configurationCustomFields] ); const thirdStep = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 5cbae4db77261e..7bc02f8a3cd288 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -51,9 +51,6 @@ export const schema: FormSchema<TemplateFormProps> = { templateDescription: { label: i18n.DESCRIPTION, validations: [ - { - validator: emptyField(i18n.REQUIRED_FIELD(i18n.DESCRIPTION)), - }, { validator: maxLengthField({ length: MAX_TEMPLATE_DESCRIPTION_LENGTH, diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx new file mode 100644 index 00000000000000..10ebc0e89e556e --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.test.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 { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { TemplateTags } from './template_tags'; +import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl'; + +describe('TemplateTags', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template tags', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={false} tags={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + }); + + it('renders loading state', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={true} tags={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByLabelText('Loading')).toBeInTheDocument(); + }); + + it('shows template tags options', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={false} tags={['foo', 'bar', 'test']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + await showEuiComboBoxOptions(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + }); + + it('adds template tag ', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={false} tags={[]} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + userEvent.paste(comboBoxEle, 'template'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['test', 'template'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx new file mode 100644 index 00000000000000..16c37154a650ff --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_tags.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 React, { memo } from 'react'; + +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import * as i18n from './translations'; +interface Props { + isLoading: boolean; + tags: string[]; +} + +const TemplateTagsComponent: React.FC<Props> = ({ isLoading, tags }) => { + const options = tags.map((label) => ({ + label, + })); + + return ( + <UseField + path="templateTags" + component={ComboBoxField} + defaultValue={[]} + componentProps={{ + idAria: 'template-tags', + 'data-test-subj': 'template-tags', + euiFieldProps: { + fullWidth: true, + placeholder: '', + disabled: isLoading, + isLoading, + options, + noSuggestions: false, + customOptionText: i18n.ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX, + }, + }} + /> + ); +}; + +TemplateTagsComponent.displayName = 'TemplateTagsComponent'; + +export const TemplateTags = memo(TemplateTagsComponent); 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 f744b1b613edc4..dc07909cb32560 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -512,7 +512,6 @@ describe('client', () => { templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ key: 'template_1', name: 'template 1', - description: 'test', caseFields: null, }), }, @@ -541,7 +540,6 @@ describe('client', () => { { key: 'template_1', name: 'template 2', - description: 'test', tags: [], caseFields: { title: 'Case title', diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 549e3c462f8806..faf2517fbe1739 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -45,7 +45,7 @@ type PersistedCustomFieldsConfiguration = Array<{ type PersistedTemplatesConfiguration = Array<{ key: string; name: string; - description: string; + description?: string; tags?: string[]; caseFields?: CaseFieldsAttributes | null; }>; From ef4dc10492989785debeea280e38bb18e15f4720 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 3 Jun 2024 09:23:21 +0100 Subject: [PATCH 21/28] fix flaky functional test --- .../test/functional_with_es_ssl/apps/cases/group2/configure.ts | 3 +-- .../functional/test_suites/observability/cases/configure.ts | 2 -- .../functional/test_suites/security/ftr/cases/configure.ts | 3 +-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index d3c909595006b0..51517cc6f47868 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CaseSeverity, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -169,7 +169,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.create.setTags('tagme'); await cases.create.setCategory('new'); - await cases.create.setSeverity(CaseSeverity.HIGH); await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index 0e670e88a9b3df..4f3cb27117d516 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { CaseSeverity } from '@kbn/cases-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -165,7 +164,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.create.setTags('tagme'); await cases.create.setCategory('new'); - await cases.create.setSeverity(CaseSeverity.HIGH); await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index 43267af84682ca..385245092667ae 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { CaseSeverity, SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; import { navigateToCasesApp } from '../../../../../shared/lib/cases/helpers'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -164,7 +164,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.create.setTags('tagme'); await cases.create.setCategory('new'); - await cases.create.setSeverity(CaseSeverity.HIGH); await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); From d298b5a4a6f01ef3e4f55190d181d0d614711f0e Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 3 Jun 2024 12:37:28 +0100 Subject: [PATCH 22/28] add retry to wait for templates-list to exist, add optional label to description --- .../components/templates/form_fields.tsx | 2 ++ .../apps/cases/group2/configure.ts | 5 +++- .../observability/cases/configure.ts | 26 +++---------------- .../security/ftr/cases/configure.ts | 26 +++---------------- 4 files changed, 12 insertions(+), 47 deletions(-) diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 95ca2041e6b312..60826b08ed64ea 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -18,6 +18,7 @@ import * as i18n from './translations'; import { Connector } from './connector'; import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; +import { OptionalFieldLabel } from '../create/optional_field_label'; import { TemplateTags } from './template_tags'; interface FormFieldsProps { @@ -57,6 +58,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ path="templateDescription" component={TextAreaField} componentProps={{ + labelAppend: OptionalFieldLabel, euiFieldProps: { 'data-test-subj': 'template-description-input', fullWidth: true, diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index 51517cc6f47868..77480a81d6fca8 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -17,6 +17,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const header = getPageObject('header'); const comboBox = getService('comboBox'); const find = getService('find'); + const retry = getService('retry'); describe('Configure', function () { before(async () => { @@ -173,7 +174,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); - await testSubjects.existOrFail('templates-list'); + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index 4f3cb27117d516..3ceebc5f04c147 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -124,28 +124,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('Templates', function () { - before(async () => { - await cases.api.createConfigWithTemplates({ - templates: [ - { - key: 'o11y_template', - name: 'My template 1', - description: 'this is my first template', - tags: ['foo'], - caseFields: null, - }, - ], - owner: 'observability', - }); - }); - - it('existing configurations do not interfere', async () => { - // A configuration created in o11y should not be visible in stack - expect(await testSubjects.getVisibleText('empty-templates')).to.be( - 'You do not have any templates yet' - ); - }); - it('adds a template', async () => { await testSubjects.existOrFail('templates-form-group'); await common.clickAndValidate('add-template', 'common-flyout'); @@ -168,7 +146,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); - await testSubjects.existOrFail('templates-list'); + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index 385245092667ae..584d4bc6507eb8 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -124,28 +124,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('Templates', function () { - before(async () => { - await cases.api.createConfigWithTemplates({ - templates: [ - { - key: 'o11y_template', - name: 'My template 1', - description: 'this is my first template', - tags: ['foo'], - caseFields: null, - }, - ], - owner: 'observability', - }); - }); - - it('existing configurations do not interfere', async () => { - // A configuration created in o11y should not be visible in stack - expect(await testSubjects.getVisibleText('empty-templates')).to.be( - 'You do not have any templates yet' - ); - }); - it('adds a template', async () => { await testSubjects.existOrFail('templates-form-group'); await common.clickAndValidate('add-template', 'common-flyout'); @@ -168,7 +146,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('common-flyout-save'); expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); - await testSubjects.existOrFail('templates-list'); + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); From 9c293635472ef823b999dd04c96430c3793addde Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:21:03 +0100 Subject: [PATCH 23/28] fix function name --- x-pack/plugins/cases/public/components/templates/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index d0dc058d4bead9..01d415254416a1 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -39,7 +39,7 @@ const TemplatesComponent: React.FC<Props> = ({ const canAddTemplates = permissions.create && permissions.update; const [error, setError] = useState<boolean>(false); - const onAddCustomField = useCallback(() => { + const onAddTemplate = useCallback(() => { if (templates.length === MAX_TEMPLATES_LENGTH && !error) { setError(true); return; @@ -92,7 +92,7 @@ const TemplatesComponent: React.FC<Props> = ({ isLoading={isLoading} isDisabled={disabled || error} size="s" - onClick={onAddCustomField} + onClick={onAddTemplate} iconType="plusInCircle" data-test-subj="add-template" > From 4f3f91f8dd6a7fef7df9a981728c9776dc39f95f Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 5 Jun 2024 11:19:55 +0100 Subject: [PATCH 24/28] add custom_fields tests, remove unnecessary args from renderBody of flyout --- .../case_form_fields/custom_fields.test.tsx | 137 ++++++++++++++++++ .../configure_cases/flyout.test.tsx | 133 ++++++----------- .../components/configure_cases/flyout.tsx | 34 +---- .../components/configure_cases/index.tsx | 29 +--- 4 files changed, 196 insertions(+), 137 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx 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 new file mode 100644 index 00000000000000..87b8decf5cbe87 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { CustomFields } from './custom_fields'; +import * as i18n from './translations'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + const defaultProps = { + configurationCustomFields: customFieldsConfigurationMock, + isLoading: false, + setAsOptional: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + for (const item of customFieldsConfigurationMock) { + expect( + await screen.findByTestId(`${item.key}-${item.type}-create-custom-field`) + ).toBeInTheDocument(); + } + }); + + it('should not show the custom fields if the configuration is empty', async () => { + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields isLoading={false} configurationCustomFields={[]} /> + </FormTestComponent> + ); + + expect(screen.queryByText(i18n.ADDITIONAL_FIELDS)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); + }); + + it('should render as optional fields for text custom fields', async () => { + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields + isLoading={false} + configurationCustomFields={customFieldsConfigurationMock} + setAsOptional={true} + /> + </FormTestComponent> + ); + + expect(screen.getAllByTestId('form-optional-field-label')).toHaveLength(2); + }); + + it('should sort the custom fields correctly', async () => { + const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse(); + + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields + isLoading={false} + configurationCustomFields={reversedCustomFieldsConfiguration} + /> + </FormTestComponent> + ); + + const customFieldsWrapper = await screen.findByTestId('caseCustomFields'); + + const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); + + expect(customFields).toHaveLength(4); + + 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'); + }); + + it('should update the custom fields', async () => { + // appMockRender = createAppMockRenderer(); + + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields {...defaultProps} /> + </FormTestComponent> + ); + + const textField = customFieldsConfigurationMock[2]; + const toggleField = customFieldsConfigurationMock[3]; + + userEvent.type( + await screen.findByTestId(`${textField.key}-${textField.type}-create-custom-field`), + 'hello' + ); + userEvent.click( + await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldsConfigurationMock[0].key]: customFieldsConfigurationMock[0].defaultValue, + [customFieldsConfigurationMock[1].key]: customFieldsConfigurationMock[1].defaultValue, + [textField.key]: 'hello', + [toggleField.key]: true, + }, + }, + true + ); + }); + }); +}); 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 f104a18fed10c5..f50c625935e0ee 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 @@ -18,14 +18,12 @@ import { MAX_TEMPLATE_DESCRIPTION_LENGTH, MAX_TEMPLATE_NAME_LENGTH, } from '../../../common/constants'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; -import type { TemplateFormProps } from '../templates/types'; import * as i18n from './translations'; import type { FlyOutBodyProps } from './flyout'; import { CommonFlyout } from './flyout'; @@ -42,7 +40,6 @@ describe('CommonFlyout ', () => { onSaveField: jest.fn(), isLoading: false, disabled: false, - data: null, renderHeader: () => <div>{`Flyout header`}</div>, renderBody: () => <div>{`This is a flyout body`}</div>, }; @@ -109,11 +106,8 @@ describe('CommonFlyout ', () => { }); describe('CustomFieldsFlyout', () => { - const renderBody = ({ - initialValue, - onChange, - }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( - <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + const renderBody = ({ onChange }: FlyOutBodyProps) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> ); const newProps = { @@ -238,11 +232,8 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - const newRenderBody = ({ - initialValue, - onChange, - }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( - <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[0]} /> ); const modifiedProps = { @@ -328,16 +319,12 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - const newRenderBody = ({ - initialValue, - onChange, - }: FlyOutBodyProps<CustomFieldConfiguration | null>) => ( - <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[1]} /> ); const modifiedProps = { ...props, - data: customFieldsConfigurationMock[1], renderBody: newRenderBody, }; @@ -360,30 +347,19 @@ describe('CommonFlyout ', () => { }); describe('TemplateFlyout', () => { - const renderBody = ({ - initialValue, - onChange, - configCustomFields, - configConnectorId, - configConnectors, - configTemplateTags, - }: FlyOutBodyProps<TemplateFormProps | null>) => ( + const renderBody = ({ onChange }: FlyOutBodyProps) => ( <TemplateForm - initialValue={initialValue} - connectors={configConnectors ?? []} - configurationConnectorId={configConnectorId ?? 'none'} - configurationCustomFields={configCustomFields ?? []} - configurationTemplateTags={configTemplateTags ?? []} + initialValue={null} + connectors={connectorsMock} + configurationConnectorId={'none'} + configurationCustomFields={[]} + configurationTemplateTags={[]} onChange={onChange} /> ); const newProps = { ...props, - connectors: connectorsMock, - configurationConnectorId: 'none', - configurationCustomFields: [], - configurationTemplateTags: [], renderBody, }; @@ -421,15 +397,26 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with case fields correctly', async () => { + const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + <TemplateForm + initialValue={{ + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }} + connectors={[]} + configurationConnectorId={'none'} + configurationCustomFields={[]} + configurationTemplateTags={[]} + onChange={onChange} + /> + ); + appMockRender.render( <CommonFlyout {...{ ...newProps, - data: { - key: 'random_key', - name: 'Template 1', - templateDescription: 'test description', - }, + renderBody: newRenderBody, }} /> ); @@ -463,35 +450,23 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField form with custom fields correctly', async () => { - const newRenderBody = ({ - initialValue, - onChange, - configCustomFields, - configConnectorId, - configConnectors, - configTemplateTags, - }: FlyOutBodyProps<TemplateFormProps | null>) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( <TemplateForm - initialValue={initialValue} - connectors={configConnectors ?? []} - configurationConnectorId={configConnectorId ?? 'none'} - configurationCustomFields={configCustomFields ?? []} - configurationTemplateTags={configTemplateTags ?? []} + initialValue={{ + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }} + connectors={[]} + configurationConnectorId={'none'} + configurationCustomFields={customFieldsConfigurationMock} + configurationTemplateTags={[]} onChange={onChange} /> ); const modifiedProps = { ...props, - connectors: [], - configurationConnectorId: 'none', - configurationCustomFields: customFieldsConfigurationMock, - configurationTemplateTags: [], - data: { - key: 'random_key', - name: 'Template 1', - templateDescription: 'test description', - }, renderBody: newRenderBody, }; @@ -525,35 +500,23 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with connector fields correctly', async () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - const newRenderBody = ({ - initialValue, - onChange, - configCustomFields, - configConnectorId, - configConnectors, - configTemplateTags, - }: FlyOutBodyProps<TemplateFormProps | null>) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( <TemplateForm - initialValue={initialValue} - connectors={configConnectors ?? []} - configurationConnectorId={configConnectorId ?? 'none'} - configurationCustomFields={configCustomFields ?? []} - configurationTemplateTags={configTemplateTags ?? []} + initialValue={{ + key: 'random_key', + name: 'Template 1', + templateDescription: 'test description', + }} + connectors={connectorsMock} + configurationConnectorId={'servicenow-1'} + configurationCustomFields={[]} + configurationTemplateTags={[]} onChange={onChange} /> ); const modifiedProps = { ...props, - connectors: connectorsMock, - configurationConnectorId: 'servicenow-1', - configurationCustomFields: [], - configurationTemplateTags: [], - data: { - key: 'random_key', - name: 'Template 1', - templateDescription: 'test description', - }, renderBody: newRenderBody, }; diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index b75a7034d893ef..ad88017b22d29a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -19,19 +19,13 @@ import { } from '@elastic/eui'; import type { CustomFieldFormState } from '../custom_fields/form'; import type { TemplateFormState } from '../templates/form'; -import type { ActionConnector, CustomFieldConfiguration } from '../../../common/types/domain'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; import * as i18n from './translations'; import type { TemplateFormProps } from '../templates/types'; -import type { CasesConfigurationUI } from '../../containers/types'; -export interface FlyOutBodyProps<T> { - initialValue: T; +export interface FlyOutBodyProps { onChange: (state: CustomFieldFormState | TemplateFormState) => void; - configConnectors?: ActionConnector[]; - configConnectorId?: string; - configCustomFields?: CasesConfigurationUI['customFields']; - configTemplateTags?: string[]; } export interface FlyoutProps<T> { @@ -39,20 +33,8 @@ export interface FlyoutProps<T> { isLoading: boolean; onCloseFlyout: () => void; onSaveField: (data: T) => void; - data: T; - connectors?: ActionConnector[]; - configurationConnectorId?: string; - configurationCustomFields?: CasesConfigurationUI['customFields']; - configurationTemplateTags?: string[]; renderHeader: () => React.ReactNode; - renderBody: ({ - initialValue, - onChange, - configConnectors, - configConnectorId, - configCustomFields, - configTemplateTags, - }: FlyOutBodyProps<T>) => React.ReactNode; + renderBody: ({ onChange }: FlyOutBodyProps) => React.ReactNode; } export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormProps | null>({ @@ -60,13 +42,8 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr onSaveField, isLoading, disabled, - data: initialValue, renderHeader, renderBody, - connectors, - configurationConnectorId, - configurationCustomFields, - configurationTemplateTags, }: FlyoutProps<T>) => { const [formState, setFormState] = useState<CustomFieldFormState | TemplateFormState>({ isValid: undefined, @@ -95,11 +72,6 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr </EuiFlyoutHeader> <EuiFlyoutBody> {renderBody({ - initialValue, - configConnectors: connectors, - configConnectorId: configurationConnectorId, - configCustomFields: configurationCustomFields, - configTemplateTags: configurationTemplateTags, onChange: setFormState, })} </EuiFlyoutBody> 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 6891e3de1cb889..7c75ae8d223376 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -441,10 +441,9 @@ export const ConfigureCases: React.FC = React.memo(() => { } onCloseFlyout={onCloseCustomFieldFlyout} onSaveField={onCustomFieldSave} - data={customFieldToEdit as CustomFieldConfiguration} renderHeader={() => <span>{i18n.ADD_CUSTOM_FIELD}</span>} - renderBody={({ initialValue, onChange }) => ( - <CustomFieldsForm onChange={onChange} initialValue={initialValue} /> + renderBody={({ onChange }) => ( + <CustomFieldsForm onChange={onChange} initialValue={customFieldToEdit} /> )} /> ) : null; @@ -464,26 +463,14 @@ export const ConfigureCases: React.FC = React.memo(() => { } onCloseFlyout={onCloseTemplateFlyout} onSaveField={onTemplateSave} - data={templateToEdit as TemplateFormProps | null} - connectors={connectors} - configurationConnectorId={connector.id} - configurationCustomFields={customFields} - configurationTemplateTags={configurationTemplateTags} renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} - renderBody={({ - initialValue, - configConnectors, - configConnectorId, - configCustomFields, - configTemplateTags, - onChange, - }) => ( + renderBody={({ onChange }) => ( <TemplateForm - initialValue={initialValue} - connectors={configConnectors ?? []} - configurationConnectorId={configConnectorId ?? ''} - configurationCustomFields={configCustomFields ?? []} - configurationTemplateTags={configTemplateTags ?? []} + initialValue={templateToEdit as TemplateFormProps | null} + connectors={connectors ?? []} + configurationConnectorId={connector.id ?? ''} + configurationCustomFields={customFields ?? []} + configurationTemplateTags={configurationTemplateTags ?? []} onChange={onChange} /> )} From 83796795d869900f20aad879f0d020a4a0d773a0 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:06:59 +0100 Subject: [PATCH 25/28] PR feedback 1 --- .../case_form_fields/custom_fields.test.tsx | 11 ++- .../case_form_fields/custom_fields.tsx | 6 +- .../case_form_fields/index.test.tsx | 24 +----- .../components/case_form_fields/index.tsx | 14 ++-- .../configure_cases/flyout.test.tsx | 21 +++-- .../components/configure_cases/flyout.tsx | 20 +++-- .../components/configure_cases/index.test.tsx | 3 +- .../components/configure_cases/index.tsx | 78 ++++++++----------- .../components/custom_fields/form.test.tsx | 30 +++---- .../public/components/custom_fields/form.tsx | 9 +-- .../use_markdown_session_storage.test.tsx | 1 + .../use_markdown_session_storage.tsx | 4 +- .../public/components/templates/form.test.tsx | 48 +++++++----- .../public/components/templates/form.tsx | 9 +-- .../components/templates/form_fields.test.tsx | 28 ++++++- .../components/templates/form_fields.tsx | 57 +++++--------- .../components/templates/index.test.tsx | 4 +- .../public/components/templates/index.tsx | 17 ++-- .../templates/template_fields.test.tsx | 73 +++++++++++++++++ .../components/templates/template_fields.tsx | 50 ++++++++++++ .../components/templates/translations.ts | 4 + .../public/components/templates/utils.test.ts | 17 +++- .../public/components/templates/utils.ts | 52 +++++-------- 23 files changed, 349 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/templates/template_fields.test.tsx create mode 100644 x-pack/plugins/cases/public/components/templates/template_fields.tsx 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 87b8decf5cbe87..9093a6f6a09c4d 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 @@ -23,7 +23,7 @@ describe('CustomFields', () => { const defaultProps = { configurationCustomFields: customFieldsConfigurationMock, isLoading: false, - setAsOptional: false, + setCustomFieldsOptional: false, }; beforeEach(() => { @@ -51,7 +51,11 @@ describe('CustomFields', () => { it('should not show the custom fields if the configuration is empty', async () => { appMockRender.render( <FormTestComponent onSubmit={onSubmit}> - <CustomFields isLoading={false} configurationCustomFields={[]} /> + <CustomFields + isLoading={false} + setCustomFieldsOptional={false} + configurationCustomFields={[]} + /> </FormTestComponent> ); @@ -65,7 +69,7 @@ describe('CustomFields', () => { <CustomFields isLoading={false} configurationCustomFields={customFieldsConfigurationMock} - setAsOptional={true} + setCustomFieldsOptional={true} /> </FormTestComponent> ); @@ -80,6 +84,7 @@ describe('CustomFields', () => { <FormTestComponent onSubmit={onSubmit}> <CustomFields isLoading={false} + setCustomFieldsOptional={false} configurationCustomFields={reversedCustomFieldsConfiguration} /> </FormTestComponent> diff --git a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx index 56354119b01bf6..977f1691712178 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/custom_fields.tsx @@ -15,13 +15,13 @@ import * as i18n from './translations'; interface Props { isLoading: boolean; - setAsOptional?: boolean; + setCustomFieldsOptional: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; } const CustomFieldsComponent: React.FC<Props> = ({ isLoading, - setAsOptional, + setCustomFieldsOptional, configurationCustomFields, }) => { const sortedCustomFields = useMemo( @@ -41,7 +41,7 @@ const CustomFieldsComponent: React.FC<Props> = ({ isLoading={isLoading} customFieldConfiguration={customField} key={customField.key} - setAsOptional={setAsOptional} + setAsOptional={setCustomFieldsOptional} /> ); } 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 ad0d6b54d35114..3803a059b6db44 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 @@ -56,7 +56,6 @@ describe('CaseFormFields', () => { expect(await screen.findByTestId('caseCategory')).toBeInTheDocument(); expect(await screen.findByTestId('caseSeverity')).toBeInTheDocument(); expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); - expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); }); it('does not render customFields when empty', () => { @@ -108,20 +107,6 @@ describe('CaseFormFields', () => { expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); - it('does not render syncAlerts when feature is not enabled', () => { - appMock = createAppMockRenderer({ - features: { alerts: { sync: false, enabled: true } }, - }); - - appMock.render( - <FormTestComponent onSubmit={onSubmit}> - <CaseFormFields {...defaultProps} /> - </FormTestComponent> - ); - - expect(screen.queryByTestId('caseSyncAlerts')).not.toBeInTheDocument(); - }); - it('calls onSubmit with case fields', async () => { appMock.render( <FormTestComponent onSubmit={onSubmit}> @@ -145,7 +130,7 @@ describe('CaseFormFields', () => { const caseCategory = await screen.findByTestId('caseCategory'); userEvent.type(within(caseCategory).getByRole('combobox'), 'new {enter}'); - userEvent.click(screen.getByText('Submit')); + userEvent.click(await screen.findByText('Submit')); await waitFor(() => { expect(onSubmit).toBeCalledWith( @@ -154,7 +139,6 @@ describe('CaseFormFields', () => { tags: ['template-1'], description: 'This is a case description', title: 'Case with Template 1', - syncAlerts: true, }, true ); @@ -189,14 +173,13 @@ describe('CaseFormFields', () => { await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) ); - userEvent.click(screen.getByText('Submit')); + userEvent.click(await screen.findByText('Submit')); await waitFor(() => { expect(onSubmit).toBeCalledWith( { category: null, tags: [], - syncAlerts: true, customFields: { test_key_1: 'My text test value 1', test_key_2: false, @@ -229,14 +212,13 @@ describe('CaseFormFields', () => { userEvent.click(screen.getByText(`${userProfiles[0].user.full_name}`)); - userEvent.click(screen.getByText('Submit')); + userEvent.click(await screen.findByText('Submit')); await waitFor(() => { expect(onSubmit).toBeCalledWith( { category: null, tags: [], - syncAlerts: true, assignees: [{ uid: userProfiles[0].uid }], }, true diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index 4b99b21c1e783a..d47929e6761824 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -15,16 +15,20 @@ import { Description } from '../create/description'; import { useCasesFeatures } from '../../common/use_cases_features'; import { Assignees } from '../create/assignees'; import { CustomFields } from './custom_fields'; -import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; import type { CasesConfigurationUI } from '../../containers/types'; interface Props { isLoading: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; } -const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading, configurationCustomFields }) => { - const { caseAssignmentAuthorized, isSyncAlertsEnabled } = useCasesFeatures(); +const CaseFormFieldsComponent: React.FC<Props> = ({ + isLoading, + configurationCustomFields, + setCustomFieldsOptional = false, +}) => { + const { caseAssignmentAuthorized } = useCasesFeatures(); return ( <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> @@ -40,11 +44,9 @@ const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading, configurationCust <Description isLoading={isLoading} /> - {isSyncAlertsEnabled ? <SyncAlertsToggle isLoading={isLoading} /> : null} - <CustomFields isLoading={isLoading} - setAsOptional={true} + setCustomFieldsOptional={setCustomFieldsOptional} configurationCustomFields={configurationCustomFields} /> </EuiFlexGroup> 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 f50c625935e0ee..52666e887ca85e 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 @@ -18,6 +18,7 @@ import { MAX_TEMPLATE_DESCRIPTION_LENGTH, MAX_TEMPLATE_NAME_LENGTH, } from '../../../common/constants'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; @@ -27,6 +28,7 @@ import { TemplateForm } from '../templates/form'; import * as i18n from './translations'; import type { FlyOutBodyProps } from './flyout'; import { CommonFlyout } from './flyout'; +import type { TemplateFormProps } from '../templates/types'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -61,7 +63,7 @@ describe('CommonFlyout ', () => { it('renders flyout header correctly', async () => { appMockRender.render(<CommonFlyout {...props} />); - expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent('Flyout header'); + expect(await screen.findByText('Flyout header')); }); it('renders loading state correctly', async () => { @@ -106,7 +108,7 @@ describe('CommonFlyout ', () => { }); describe('CustomFieldsFlyout', () => { - const renderBody = ({ onChange }: FlyOutBodyProps) => ( + const renderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( <CustomFieldsForm onChange={onChange} initialValue={null} /> ); @@ -232,7 +234,7 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[0]} /> ); @@ -319,7 +321,7 @@ describe('CommonFlyout ', () => { }); it('renders flyout with the correct data when an initial customField value exists', async () => { - const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[1]} /> ); @@ -347,7 +349,7 @@ describe('CommonFlyout ', () => { }); describe('TemplateFlyout', () => { - const renderBody = ({ onChange }: FlyOutBodyProps) => ( + const renderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={null} connectors={connectorsMock} @@ -392,12 +394,13 @@ describe('CommonFlyout ', () => { templateTags: ['foo'], connectorId: 'none', syncAlerts: true, + fields: null, }); }); }); it('calls onSaveField with case fields correctly', async () => { - const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={{ key: 'random_key', @@ -445,12 +448,13 @@ describe('CommonFlyout ', () => { category: 'new', connectorId: 'none', syncAlerts: true, + fields: null, }); }); }); it('calls onSaveField form with custom fields correctly', async () => { - const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={{ key: 'random_key', @@ -493,6 +497,7 @@ describe('CommonFlyout ', () => { [customFieldsConfigurationMock[1].key]: true, [customFieldsConfigurationMock[3].key]: false, }, + fields: null, }); }); }); @@ -500,7 +505,7 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with connector fields correctly', async () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - const newRenderBody = ({ onChange }: FlyOutBodyProps) => ( + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={{ key: 'random_key', diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index ad88017b22d29a..703040fce6c039 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -17,15 +17,19 @@ import { EuiButtonEmpty, EuiButton, } from '@elastic/eui'; -import type { CustomFieldFormState } from '../custom_fields/form'; -import type { TemplateFormState } from '../templates/form'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import * as i18n from './translations'; import type { TemplateFormProps } from '../templates/types'; -export interface FlyOutBodyProps { - onChange: (state: CustomFieldFormState | TemplateFormState) => void; +export interface FormState<T> { + isValid: boolean | undefined; + submit: FormHook<T>['submit']; +} + +export interface FlyOutBodyProps<T> { + onChange: (state: FormState<T>) => void; } export interface FlyoutProps<T> { @@ -34,10 +38,10 @@ export interface FlyoutProps<T> { onCloseFlyout: () => void; onSaveField: (data: T) => void; renderHeader: () => React.ReactNode; - renderBody: ({ onChange }: FlyOutBodyProps) => React.ReactNode; + renderBody: ({ onChange }: FlyOutBodyProps<T>) => React.ReactNode; } -export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormProps | null>({ +export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormProps>({ onCloseFlyout, onSaveField, isLoading, @@ -45,11 +49,11 @@ export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormPr renderHeader, renderBody, }: FlyoutProps<T>) => { - const [formState, setFormState] = useState<CustomFieldFormState | TemplateFormState>({ + const [formState, setFormState] = useState<FormState<T>>({ isValid: undefined, submit: async () => ({ isValid: false, - data: {}, + data: {} as T, }), }); 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 b6de3c4e69b4bc..aa4c6395e5c73c 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 @@ -830,12 +830,11 @@ describe('ConfigureCases', () => { describe('templates', () => { let appMockRender: AppMockRenderer; - let persistCaseConfigure: jest.Mock; + const persistCaseConfigure = jest.fn(); beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); - persistCaseConfigure = jest.fn(); usePersistConfigurationMock.mockImplementation(() => ({ ...usePersistConfigurationMockResponse, mutate: persistCaseConfigure, 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 7c75ae8d223376..44f6d2eda9eda0 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -22,11 +22,7 @@ import { import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { - CustomFieldConfiguration, - CustomFieldsConfiguration, - TemplateConfiguration, -} from '../../../common/types/domain'; +import type { CustomFieldConfiguration, TemplateConfiguration } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; @@ -336,12 +332,12 @@ export const ConfigureCases: React.FC = React.memo(() => { }, [setFlyOutVisibility, setCustomFieldToEdit]); const onCustomFieldSave = useCallback( - (data: CustomFieldConfiguration | null) => { - const updatedCustomFields = addOrReplaceField(customFields, data as CustomFieldConfiguration); + (data: CustomFieldConfiguration) => { + const updatedCustomFields = addOrReplaceField(customFields, data); persistCaseConfigure({ connector, - customFields: updatedCustomFields as CustomFieldsConfiguration, + customFields: updatedCustomFields, templates, id: configurationId, version: configurationVersion, @@ -368,7 +364,7 @@ export const ConfigureCases: React.FC = React.memo(() => { }, [setFlyOutVisibility, setTemplateToEdit]); const onTemplateSave = useCallback( - (data: TemplateFormProps | null) => { + (data: TemplateFormProps) => { const { connectorId, fields, @@ -379,7 +375,7 @@ export const ConfigureCases: React.FC = React.memo(() => { templateTags, templateDescription, ...otherCaseFields - } = (data ?? {}) as TemplateFormProps; + } = data; const transformedCustomFields = templateCustomFields ? transformCustomFieldsData(templateCustomFields, customFields) @@ -429,8 +425,8 @@ export const ConfigureCases: React.FC = React.memo(() => { ] ); - const AddOrEditCustomFieldFlyout = useMemo(() => { - return flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( + const AddOrEditCustomFieldFlyout = + flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( <CommonFlyout<CustomFieldConfiguration> isLoading={loadingCaseConfigure || isPersistingConfiguration} disabled={ @@ -447,38 +443,32 @@ export const ConfigureCases: React.FC = React.memo(() => { )} /> ) : null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [flyOutVisibility]); - const AddOrEditTemplateFlyout = useMemo( - () => - flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( - <CommonFlyout<TemplateFormProps | null> - isLoading={loadingCaseConfigure || isPersistingConfiguration} - disabled={ - !permissions.create || - !permissions.update || - loadingCaseConfigure || - isPersistingConfiguration - } - onCloseFlyout={onCloseTemplateFlyout} - onSaveField={onTemplateSave} - renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} - renderBody={({ onChange }) => ( - <TemplateForm - initialValue={templateToEdit as TemplateFormProps | null} - connectors={connectors ?? []} - configurationConnectorId={connector.id ?? ''} - configurationCustomFields={customFields ?? []} - configurationTemplateTags={configurationTemplateTags ?? []} - onChange={onChange} - /> - )} - /> - ) : null, - // eslint-disable-next-line react-hooks/exhaustive-deps - [flyOutVisibility] - ); + const AddOrEditTemplateFlyout = + flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( + <CommonFlyout<TemplateFormProps> + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={ + !permissions.create || + !permissions.update || + loadingCaseConfigure || + isPersistingConfiguration + } + onCloseFlyout={onCloseTemplateFlyout} + onSaveField={onTemplateSave} + renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} + renderBody={({ onChange }) => ( + <TemplateForm + initialValue={templateToEdit as TemplateFormProps | null} + connectors={connectors ?? []} + configurationConnectorId={connector.id ?? ''} + configurationCustomFields={customFields ?? []} + configurationTemplateTags={configurationTemplateTags ?? []} + onChange={onChange} + /> + )} + /> + ) : null; return ( <EuiPageSection restrictWidth={true}> @@ -563,7 +553,7 @@ export const ConfigureCases: React.FC = React.memo(() => { templates={templates} isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} - handleAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} /> </EuiFlexItem> </div> 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 index ef2cbac458678c..89fdca73fefbff 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx @@ -10,13 +10,13 @@ import { screen, fireEvent, waitFor, act } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import type { CustomFieldFormState } from './form'; import { CustomFieldsForm } from './form'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import * as i18n from './translations'; import userEvent from '@testing-library/user-event'; import { customFieldsConfigurationMock } from '../../containers/mock'; +import type { FormState } from '../configure_cases/flyout'; describe('CustomFieldsForm ', () => { let appMockRender: AppMockRenderer; @@ -68,9 +68,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -96,9 +96,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is not filled', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -122,9 +122,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is selected and the text default value is an empty string', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -149,9 +149,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if the initial default value is null', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); const initialValue = { required: true, @@ -190,9 +190,9 @@ describe('CustomFieldsForm ', () => { }); it('serializes the data correctly if required is not selected', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render(<CustomFieldsForm onChange={onChangeState} initialValue={null} />); @@ -215,9 +215,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: text" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[0]} /> @@ -247,9 +247,9 @@ describe('CustomFieldsForm ', () => { }); it('deserializes the "type: toggle" custom field data correctly', async () => { - let formState: CustomFieldFormState; + let formState: FormState<CustomFieldConfiguration>; - const onChangeState = (state: CustomFieldFormState) => (formState = state); + const onChangeState = (state: FormState<CustomFieldConfiguration>) => (formState = state); appMockRender.render( <CustomFieldsForm onChange={onChangeState} initialValue={customFieldsConfigurationMock[1]} /> diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx index 9b7596075b01b5..2a2c675aac31db 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/form.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -15,14 +14,10 @@ import { FormFields } from './form_fields'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import { customFieldSerializer } from './utils'; - -export interface CustomFieldFormState { - isValid: boolean | undefined; - submit: FormHook<CustomFieldConfiguration | {}>['submit']; -} +import type { FormState } from '../configure_cases/flyout'; interface Props { - onChange: (state: CustomFieldFormState) => void; + onChange: (state: FormState<CustomFieldConfiguration>) => void; initialValue: CustomFieldConfiguration | null; } diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx index 9ba64701189a86..e4ce68ed452375 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx @@ -60,6 +60,7 @@ describe('useMarkdownSessionStorage', () => { ); await waitFor(() => { + expect(field.setValue).not.toHaveBeenCalled(); expect(result.current.hasConflicts).toBe(false); }); }); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx index 4505802181c426..0a82d43cc093d2 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx @@ -45,7 +45,9 @@ export const useMarkdownSessionStorage = ({ useDebounce( () => { - setSessionValue(field.value); + if (!isEmpty(sessionKey)) { + setSessionValue(field.value); + } }, STORAGE_DEBOUNCE_TIME, [field.value] 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 c8e55019b67a68..eae9e7c9ff3208 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -20,8 +20,9 @@ import { CustomFieldTypes } from '../../../common/types/domain'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; -import type { TemplateFormState } from './form'; +import type { FormState } from '../configure_cases/flyout'; import { TemplateForm } from './form'; +import type { TemplateFormProps } from './types'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -126,9 +127,9 @@ describe('TemplateForm', () => { }); it('serializes the template field data correctly', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -162,14 +163,15 @@ describe('TemplateForm', () => { templateTags: ['foo', 'bar'], connectorId: 'none', syncAlerts: true, + fields: null, }); }); }); it('serializes the case field data correctly', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -215,14 +217,15 @@ describe('TemplateForm', () => { category: 'new', connectorId: 'none', syncAlerts: true, + fields: null, }); }); }); it('serializes the connector fields data correctly', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render( <TemplateForm @@ -274,9 +277,9 @@ describe('TemplateForm', () => { }); it('serializes the custom fields data correctly', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render( <TemplateForm @@ -299,9 +302,11 @@ describe('TemplateForm', () => { 'this is a first template' ); - const customFieldsEle = await screen.findByTestId('caseCustomFields'); + const customFieldsElement = await screen.findByTestId('caseCustomFields'); - expect(await within(customFieldsEle).findAllByTestId('form-optional-field-label')).toHaveLength( + expect( + await within(customFieldsElement).findAllByTestId('form-optional-field-label') + ).toHaveLength( customFieldsConfigurationMock.filter((field) => field.type === CustomFieldTypes.TEXT).length ); @@ -336,14 +341,15 @@ describe('TemplateForm', () => { test_key_2: true, test_key_4: true, }, + fields: null, }); }); }); it('shows form state as invalid when template name missing', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -363,9 +369,9 @@ describe('TemplateForm', () => { }); it('shows from state as invalid when template name is too long', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -387,9 +393,9 @@ describe('TemplateForm', () => { }); it('shows from state as invalid when template description is too long', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -411,9 +417,9 @@ describe('TemplateForm', () => { }); it('shows from state as invalid when template tags are more than 10', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); @@ -440,9 +446,9 @@ describe('TemplateForm', () => { }); it('shows from state as invalid when template tag is more than 50 characters', async () => { - let formState: TemplateFormState; + let formState: FormState<TemplateFormProps>; - const onChangeState = (state: TemplateFormState) => (formState = state); + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 74ea0649a65833..20ee683045fc77 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -15,14 +14,10 @@ import { FormFields } from './form_fields'; import { templateSerializer } from './utils'; import type { TemplateFormProps } from './types'; import type { CasesConfigurationUI } from '../../containers/types'; - -export interface TemplateFormState { - isValid: boolean | undefined; - submit: FormHook<TemplateFormProps | {}>['submit']; -} +import type { FormState } from '../configure_cases/flyout'; interface Props { - onChange: (state: TemplateFormState) => void; + onChange: (state: FormState<TemplateFormProps>) => void; initialValue: TemplateFormProps | null; connectors: ActionConnector[]; configurationConnectorId: string; 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 30f899578fa339..069fd1c422991d 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 @@ -14,7 +14,7 @@ import { FormTestComponent } from '../../common/test_utils'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; -import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS } from './translations'; +import { TEMPLATE_FIELDS, CASE_FIELDS, CONNECTOR_FIELDS, CASE_SETTINGS } from './translations'; import { FormFields } from './form_fields'; jest.mock('../connectors/servicenow/use_get_choices'); @@ -56,6 +56,7 @@ describe('form fields', () => { expect(await screen.findByText(TEMPLATE_FIELDS)).toBeInTheDocument(); expect(await screen.findByText(CASE_FIELDS)).toBeInTheDocument(); + expect(await screen.findByText(CASE_SETTINGS)).toBeInTheDocument(); expect(await screen.findByText(CONNECTOR_FIELDS)).toBeInTheDocument(); }); @@ -66,6 +67,7 @@ describe('form fields', () => { </FormTestComponent> ); + expect(await screen.findByTestId('template-fields')).toBeInTheDocument(); expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); @@ -86,6 +88,16 @@ describe('form fields', () => { expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); }); + it('renders sync alerts correctly', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + }); + it('renders custom fields correctly', async () => { const newProps = { ...defaultProps, @@ -127,6 +139,20 @@ describe('form fields', () => { expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); }); + it('does not render sync alerts when feature is not enabled', () => { + appMockRenderer = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); + + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(screen.queryByTestId('caseSyncAlerts')).not.toBeInTheDocument(); + }); + it('calls onSubmit with template fields', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 60826b08ed64ea..c0d93d0beedffd 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -7,19 +7,16 @@ import React, { memo, useMemo } from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { - TextField, - HiddenField, - TextAreaField, -} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiSteps } from '@elastic/eui'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; import { Connector } from './connector'; import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; -import { OptionalFieldLabel } from '../create/optional_field_label'; -import { TemplateTags } from './template_tags'; +import { TemplateFields } from './template_fields'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; interface FormFieldsProps { isSubmitting?: boolean; @@ -36,37 +33,16 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ configurationCustomFields, configurationTemplateTags, }) => { + const { isSyncAlertsEnabled } = useCasesFeatures(); + const firstStep = useMemo( () => ({ title: i18n.TEMPLATE_FIELDS, children: ( - <> - <UseField - path="name" - component={TextField} - componentProps={{ - euiFieldProps: { - 'data-test-subj': 'template-name-input', - fullWidth: true, - autoFocus: true, - isLoading: isSubmitting, - }, - }} - /> - <TemplateTags isLoading={isSubmitting} tags={configurationTemplateTags} /> - <UseField - path="templateDescription" - component={TextAreaField} - componentProps={{ - labelAppend: OptionalFieldLabel, - euiFieldProps: { - 'data-test-subj': 'template-description-input', - fullWidth: true, - isLoading: isSubmitting, - }, - }} - /> - </> + <TemplateFields + isLoading={isSubmitting} + configurationTemplateTags={configurationTemplateTags} + /> ), }), [isSubmitting, configurationTemplateTags] @@ -79,6 +55,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ <CaseFormFields configurationCustomFields={configurationCustomFields} isLoading={isSubmitting} + setCustomFieldsOptional={true} /> ), }), @@ -86,6 +63,14 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ ); const thirdStep = useMemo( + () => ({ + title: i18n.CASE_SETTINGS, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( () => ({ title: i18n.CONNECTOR_FIELDS, children: ( @@ -102,8 +87,8 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ ); const allSteps = useMemo( - () => [firstStep, secondStep, thirdStep], - [firstStep, secondStep, thirdStep] + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, thirdStep, fourthStep, isSyncAlertsEnabled] ); return ( diff --git a/x-pack/plugins/cases/public/components/templates/index.test.tsx b/x-pack/plugins/cases/public/components/templates/index.test.tsx index 2e2c963eb88af2..075a4d2a2bd62e 100644 --- a/x-pack/plugins/cases/public/components/templates/index.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -24,7 +24,7 @@ describe('Templates', () => { disabled: false, isLoading: false, templates: [], - handleAddTemplate: jest.fn(), + onAddTemplate: jest.fn(), }; beforeEach(() => { @@ -71,7 +71,7 @@ describe('Templates', () => { userEvent.click(await screen.findByTestId('add-template')); - expect(props.handleAddTemplate).toBeCalled(); + expect(props.onAddTemplate).toBeCalled(); }); it('shows the experimental badge', async () => { diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index 01d415254416a1..f86037ce978130 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -26,28 +26,23 @@ interface Props { disabled: boolean; isLoading: boolean; templates: CasesConfigurationUITemplate[]; - handleAddTemplate: () => void; + onAddTemplate: () => void; } -const TemplatesComponent: React.FC<Props> = ({ - disabled, - isLoading, - templates, - handleAddTemplate, -}) => { +const TemplatesComponent: React.FC<Props> = ({ disabled, isLoading, templates, onAddTemplate }) => { const { permissions } = useCasesContext(); const canAddTemplates = permissions.create && permissions.update; const [error, setError] = useState<boolean>(false); - const onAddTemplate = useCallback(() => { + const handleAddTemplate = useCallback(() => { if (templates.length === MAX_TEMPLATES_LENGTH && !error) { setError(true); return; } - handleAddTemplate(); + onAddTemplate(); setError(false); - }, [handleAddTemplate, error, templates]); + }, [onAddTemplate, error, templates]); return ( <EuiDescribedFormGroup @@ -92,7 +87,7 @@ const TemplatesComponent: React.FC<Props> = ({ isLoading={isLoading} isDisabled={disabled || error} size="s" - onClick={onAddTemplate} + onClick={handleAddTemplate} iconType="plusInCircle" data-test-subj="add-template" > diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx new file mode 100644 index 00000000000000..5d0dd30da8e699 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { TemplateFields } from './template_fields'; + +describe('Template fields', () => { + let appMockRenderer: AppMockRenderer; + const onSubmit = jest.fn(); + const defaultProps = { + isLoading: false, + configurationTemplateTags: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders template fields correctly', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toBeInTheDocument(); + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); + }); + + it('calls onSubmit with template fields', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(within(templateTags).getByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste( + await screen.findByTestId('template-description-input'), + 'this is a first template' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Template 1', + templateDescription: 'this is a first template', + templateTags: ['first'], + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx new file mode 100644 index 00000000000000..e9c34af53ca693 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, TextAreaField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup } from '@elastic/eui'; +import { OptionalFieldLabel } from '../create/optional_field_label'; +import { TemplateTags } from './template_tags'; + +const TemplateFieldsComponent: React.FC<{ + isLoading: boolean; + configurationTemplateTags: string[]; +}> = ({ isLoading = false, configurationTemplateTags }) => ( + <EuiFlexGroup data-test-subj="template-fields" direction="column"> + <UseField + path="name" + component={TextField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': 'template-name-input', + fullWidth: true, + autoFocus: true, + isLoading, + }, + }} + /> + <TemplateTags isLoading={isLoading} tags={configurationTemplateTags} /> + <UseField + path="templateDescription" + component={TextAreaField} + componentProps={{ + labelAppend: OptionalFieldLabel, + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + isLoading, + }, + }} + /> + </EuiFlexGroup> +); + +TemplateFieldsComponent.displayName = 'TemplateFields'; + +export const TemplateFields = memo(TemplateFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index db06e148f23854..c48d3bce328f74 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -57,6 +57,10 @@ export const CASE_FIELDS = i18n.translate('xpack.cases.templates.caseFields', { defaultMessage: 'Case fields', }); +export const CASE_SETTINGS = i18n.translate('xpack.cases.templates.caseSettings', { + defaultMessage: 'Case settings', +}); + export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorFields', { defaultMessage: 'External Connector Fields', }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts index 407c812dcce810..00de799ce431d1 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -21,7 +21,20 @@ describe('templateSerializer', () => { category: null, }); - expect(res).toEqual({}); + expect(res).toEqual({ fields: null }); + }); + + it('serializes connectors fields correctly', () => { + const res = templateSerializer({ + key: '', + name: '', + templateDescription: '', + fields: null, + }); + + expect(res).toEqual({ + fields: null, + }); }); it('serializes non empty fields correctly', () => { @@ -39,6 +52,7 @@ describe('templateSerializer', () => { templateDescription: 'description 1', category: 'new', templateTags: ['sample'], + fields: null, }); }); @@ -61,6 +75,7 @@ describe('templateSerializer', () => { custom_field_1: 'foobar', custom_field_3: true, }, + fields: null, }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index cfcb9586eea7c8..9ebe6ac19b2d9d 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -5,49 +5,33 @@ * 2.0. */ -import { getConnectorsFormSerializer, isEmptyValue } from '../utils'; +import { isEmpty } from 'lodash'; +import { getConnectorsFormSerializer } from '../utils'; import type { TemplateFormProps } from './types'; -export const removeEmptyFields = ( - data: TemplateFormProps | Record<string, string | boolean | null | undefined> | null | undefined -): TemplateFormProps | Record<string, string | boolean> | null => { - if (data) { - return Object.entries(data).reduce((acc, [key, value]) => { - let initialValue = {}; +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} - if (key === 'customFields') { - const nonEmptyCustomFields = - removeEmptyFields(value as Record<string, string | boolean | null | undefined>) ?? {}; - - if (Object.entries(nonEmptyCustomFields).length > 0) { - initialValue = { - customFields: nonEmptyCustomFields, - }; - } - } else if (!isEmptyValue(value)) { - initialValue = { [key]: value }; - } - - return { - ...acc, - ...initialValue, - }; - }, {}); - } - - return null; -}; - -export const templateSerializer = <T extends TemplateFormProps | null>(data: T): T => { +export const templateSerializer = (data: TemplateFormProps): TemplateFormProps => { if (data !== null) { const { fields = null, ...rest } = data; const connectorFields = getConnectorsFormSerializer({ fields }); - const serializedFields = removeEmptyFields({ ...rest, fields: connectorFields.fields }); + const serializedFields = removeEmptyFields({ ...rest }); return { ...serializedFields, - // fields: connectorFields.fields, - } as T; + fields: connectorFields.fields, + } as TemplateFormProps; } return data; From e4aa6ad4eba559285810d6b57c630cb7d4812cf2 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:01:41 +0100 Subject: [PATCH 26/28] PR feedback 2 --- .../components/case_form_fields/schema.tsx | 89 +++++++ .../components/case_form_fields/utils.test.ts | 90 +++++++ .../components/case_form_fields/utils.ts | 64 +++++ .../configure_cases/flyout.test.tsx | 46 ++-- .../components/configure_cases/flyout.tsx | 12 +- .../components/configure_cases/index.tsx | 28 +-- .../components/custom_fields/utils.test.ts | 53 ++++- .../public/components/templates/form.test.tsx | 41 +++- .../public/components/templates/form.tsx | 12 +- .../components/templates/form_fields.test.tsx | 53 ++++- .../components/templates/form_fields.tsx | 16 +- .../components/templates/schema.test.tsx | 103 ++++++++ .../public/components/templates/schema.tsx | 92 ++------ .../public/components/templates/utils.test.ts | 220 +++++++++--------- .../public/components/templates/utils.ts | 56 ----- 15 files changed, 668 insertions(+), 307 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/schema.tsx create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts create mode 100644 x-pack/plugins/cases/public/components/case_form_fields/utils.ts create mode 100644 x-pack/plugins/cases/public/components/templates/schema.test.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx new file mode 100644 index 00000000000000..a4e1dcfd66cd74 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import { SEVERITY_TITLE } from '../severity/translations'; +import type { CaseBaseOptionalFields } from '../../../common/types/domain'; +import * as i18n from './translations'; +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; + +const { maxLengthField } = fieldValidators; + +type CaseFormFieldsProps = Omit< + CaseBaseOptionalFields, + 'customFields' | 'connector' | 'settings' +> & { + customFields?: Record<string, string | boolean>; +}; + +export const schema: FormSchema<CaseFormFieldsProps> = { + title: { + label: i18n.NAME, + validations: [ + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), + }), + }, + ], + }, + description: { + label: i18n.DESCRIPTION, + validations: [ + { + validator: maxLengthField({ + length: MAX_DESCRIPTION_LENGTH, + message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), + }), + }, + ], + }, + tags: { + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + validations: [ + { + validator: ({ value }: { value: string | string[] }) => + validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string | string[] }) => + validateMaxLength({ + value, + message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), + limit: MAX_LENGTH_PER_TAG, + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, + }, + { + validator: ({ value }: { value: string[] }) => + validateMaxTagsLength({ + value, + message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), + limit: MAX_TAGS_PER_CASE, + }), + }, + ], + }, + severity: { + label: SEVERITY_TITLE, + }, + assignees: {}, + category: {}, +}; diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts new file mode 100644 index 00000000000000..a8a948d88a158f --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import * as i18n from './translations'; + +describe('utils', () => { + describe('validateEmptyTags', () => { + const message = i18n.TAGS_EMPTY_ERROR; + it('returns no error for non empty tags', () => { + expect(validateEmptyTags({ value: ['coke', 'pepsi'], message })).toBeUndefined(); + }); + + it('returns no error for non empty tag', () => { + expect(validateEmptyTags({ value: 'coke', message })).toBeUndefined(); + }); + + it('returns error for empty tags', () => { + expect(validateEmptyTags({ value: [' ', 'pepsi'], message })).toEqual({ message }); + }); + + it('returns error for empty tag', () => { + expect(validateEmptyTags({ value: ' ', message })).toEqual({ message }); + }); + }); + + describe('validateMaxLength', () => { + const limit = 5; + const message = i18n.MAX_LENGTH_ERROR('tag', limit); + + it('returns error for tags exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi!'], + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns error for tag exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello!', + message, + limit, + }) + ).toEqual({ message }); + }); + + it('returns no error for tags not exceeding length', () => { + expect( + validateMaxLength({ + value: ['coke', 'pepsi'], + message, + limit, + }) + ).toBeUndefined(); + }); + + it('returns no error for tag not exceeding length', () => { + expect( + validateMaxLength({ + value: 'Hello', + message, + limit, + }) + ).toBeUndefined(); + }); + }); + + describe('validateMaxTagsLength', () => { + const limit = 2; + const message = i18n.MAX_TAGS_ERROR(limit); + + it('returns error when tags exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi', 'fanta'], message, limit })).toEqual({ + message, + }); + }); + + it('returns no error when tags do not exceed length', () => { + expect(validateMaxTagsLength({ value: ['coke', 'pepsi'], message, limit })).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts new file mode 100644 index 00000000000000..1ef198ca5fef54 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const isInvalidTag = (value: string) => value.trim() === ''; + +const isTagCharactersInLimit = (value: string, limit: number) => value.trim().length > limit; + +export const validateEmptyTags = ({ + value, + message, +}: { + value: string | string[]; + message: string; +}) => { + if ( + (!Array.isArray(value) && isInvalidTag(value)) || + (Array.isArray(value) && value.length > 0 && value.find((item) => isInvalidTag(item))) + ) { + return { + message, + }; + } +}; + +export const validateMaxLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if ( + (!Array.isArray(value) && value.trim().length > limit) || + (Array.isArray(value) && + value.length > 0 && + value.some((item) => isTagCharactersInLimit(item, limit))) + ) { + return { + message, + }; + } +}; + +export const validateMaxTagsLength = ({ + value, + message, + limit, +}: { + value: string | string[]; + message: string; + limit: number; +}) => { + if (Array.isArray(value) && value.length > limit) { + return { + message, + }; + } +}; 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 52666e887ca85e..4872c1af6f449c 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 @@ -10,7 +10,7 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH, @@ -18,8 +18,8 @@ import { MAX_TEMPLATE_DESCRIPTION_LENGTH, MAX_TEMPLATE_NAME_LENGTH, } from '../../../common/constants'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; -import { CustomFieldTypes } from '../../../common/types/domain'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; import { FIELD_LABEL, DEFAULT_VALUE } from '../custom_fields/translations'; @@ -349,13 +349,27 @@ describe('CommonFlyout ', () => { }); describe('TemplateFlyout', () => { + const currentConfiguration = { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }; + const renderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={null} connectors={connectorsMock} - configurationConnectorId={'none'} - configurationCustomFields={[]} - configurationTemplateTags={[]} + currentConfiguration={currentConfiguration} onChange={onChange} /> ); @@ -408,9 +422,7 @@ describe('CommonFlyout ', () => { templateDescription: 'test description', }} connectors={[]} - configurationConnectorId={'none'} - configurationCustomFields={[]} - configurationTemplateTags={[]} + currentConfiguration={currentConfiguration} onChange={onChange} /> ); @@ -454,6 +466,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField form with custom fields correctly', async () => { + const newConfig = { ...currentConfiguration, customFields: customFieldsConfigurationMock }; const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm initialValue={{ @@ -462,9 +475,7 @@ describe('CommonFlyout ', () => { templateDescription: 'test description', }} connectors={[]} - configurationConnectorId={'none'} - configurationCustomFields={customFieldsConfigurationMock} - configurationTemplateTags={[]} + currentConfiguration={newConfig} onChange={onChange} /> ); @@ -504,6 +515,15 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with connector fields correctly', async () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + const newConfig = { + ...currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }; const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( <TemplateForm @@ -513,9 +533,7 @@ describe('CommonFlyout ', () => { templateDescription: 'test description', }} connectors={connectorsMock} - configurationConnectorId={'servicenow-1'} - configurationCustomFields={[]} - configurationTemplateTags={[]} + currentConfiguration={newConfig} onChange={onChange} /> ); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 703040fce6c039..130b48626b3aef 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -17,22 +17,20 @@ import { EuiButtonEmpty, EuiButton, } from '@elastic/eui'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; -import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import type { FormHook, FormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; import * as i18n from './translations'; -import type { TemplateFormProps } from '../templates/types'; -export interface FormState<T> { +export interface FormState<T extends FormData = FormData> { isValid: boolean | undefined; submit: FormHook<T>['submit']; } -export interface FlyOutBodyProps<T> { +export interface FlyOutBodyProps<T extends FormData = FormData> { onChange: (state: FormState<T>) => void; } -export interface FlyoutProps<T> { +export interface FlyoutProps<T extends FormData = FormData> { disabled: boolean; isLoading: boolean; onCloseFlyout: () => void; @@ -41,7 +39,7 @@ export interface FlyoutProps<T> { renderBody: ({ onChange }: FlyOutBodyProps<T>) => React.ReactNode; } -export const CommonFlyout = <T extends CustomFieldConfiguration | TemplateFormProps>({ +export const CommonFlyout = <T extends FormData = FormData>({ onCloseFlyout, onSaveField, isLoading, 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 44f6d2eda9eda0..a317c05b5d2787 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -84,19 +84,21 @@ export const ConfigureCases: React.FC = React.memo(() => { const { euiTheme } = useEuiTheme(); const { - data: { - id: configurationId, - version: configurationVersion, - closureType, - connector, - mappings, - customFields, - templates, - }, + data: currentConfiguration, isLoading: loadingCaseConfigure, refetch: refetchCaseConfigure, } = useGetCaseConfiguration(); + const { + id: configurationId, + version: configurationVersion, + closureType, + connector, + mappings, + customFields, + templates, + } = currentConfiguration; + const { mutate: persistCaseConfigure, mutateAsync: persistCaseConfigureAsync, @@ -104,10 +106,6 @@ export const ConfigureCases: React.FC = React.memo(() => { } = usePersistConfiguration(); const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; - const configurationTemplateTags = templates - .map((template) => (template?.tags?.length ? template.tags : [])) - .flat(); - const { isLoading: isLoadingConnectors, data: connectors = [], @@ -461,9 +459,7 @@ export const ConfigureCases: React.FC = React.memo(() => { <TemplateForm initialValue={templateToEdit as TemplateFormProps | null} connectors={connectors ?? []} - configurationConnectorId={connector.id ?? ''} - configurationCustomFields={customFields ?? []} - configurationTemplateTags={configurationTemplateTags ?? []} + currentConfiguration={currentConfiguration} onChange={onChange} /> )} diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts index 5a213196458360..3b82eddf55cad4 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { customFieldSerializer } from './utils'; +import { customFieldSerializer, transformCustomFieldsData } from './utils'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; +import { customFieldsConfigurationMock } from '../../containers/mock'; describe('utils ', () => { describe('customFieldSerializer ', () => { @@ -98,4 +99,54 @@ describe('utils ', () => { `); }); }); + + describe('transformCustomFieldsData', () => { + it('transforms customFields correctly', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(transformCustomFieldsData(customFields, customFieldsConfigurationMock)).toEqual([ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ]); + }); + + it('returns empty array when custom fields are empty', () => { + expect(transformCustomFieldsData({}, customFieldsConfigurationMock)).toEqual([]); + }); + + it('returns empty array when not custom fields in the configuration', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(transformCustomFieldsData(customFields, [])).toEqual([]); + }); + + it('returns empty array when custom fields do not match with configuration', () => { + const customFields = { + random_key: 'first value', + }; + + expect(transformCustomFieldsData(customFields, customFieldsConfigurationMock)).toEqual([]); + }); + }); }); 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 eae9e7c9ff3208..9b30f89f247132 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -9,14 +9,14 @@ import React from 'react'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; import { MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_DESCRIPTION_LENGTH, MAX_TEMPLATE_NAME_LENGTH, MAX_TEMPLATE_TAG_LENGTH, } from '../../../common/constants'; -import { CustomFieldTypes } from '../../../common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; @@ -32,9 +32,21 @@ describe('TemplateForm', () => { let appMockRenderer: AppMockRenderer; const defaultProps = { connectors: connectorsMock, - configurationConnectorId: 'none', - configurationCustomFields: [], - configurationTemplateTags: [], + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, onChange: jest.fn(), initialValue: null, }; @@ -229,7 +241,19 @@ describe('TemplateForm', () => { appMockRenderer.render( <TemplateForm - {...{ ...defaultProps, configurationConnectorId: 'servicenow-1', onChange: onChangeState }} + {...{ + ...defaultProps, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, + onChange: onChangeState, + }} /> ); @@ -285,7 +309,10 @@ describe('TemplateForm', () => { <TemplateForm {...{ ...defaultProps, - configurationCustomFields: customFieldsConfigurationMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, onChange: onChangeState, }} /> diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index 20ee683045fc77..e7ca2451bb179b 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -20,18 +20,14 @@ interface Props { onChange: (state: FormState<TemplateFormProps>) => void; initialValue: TemplateFormProps | null; connectors: ActionConnector[]; - configurationConnectorId: string; - configurationCustomFields: CasesConfigurationUI['customFields']; - configurationTemplateTags: string[]; + currentConfiguration: CasesConfigurationUI; } const FormComponent: React.FC<Props> = ({ onChange, initialValue, connectors, - configurationConnectorId, - configurationCustomFields, - configurationTemplateTags, + currentConfiguration, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -60,9 +56,7 @@ const FormComponent: React.FC<Props> = ({ <FormFields isSubmitting={isSubmitting} connectors={connectors} - configurationConnectorId={configurationConnectorId} - configurationCustomFields={configurationCustomFields} - configurationTemplateTags={configurationTemplateTags} + currentConfiguration={currentConfiguration} /> </Form> ); 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 069fd1c422991d..103dcb9135b399 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 @@ -9,7 +9,8 @@ import React from 'react'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; +import { ConnectorTypes } from '../../../common/types/domain'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; import { FormTestComponent } from '../../common/test_utils'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; @@ -26,9 +27,21 @@ describe('form fields', () => { const onSubmit = jest.fn(); const defaultProps = { connectors: connectorsMock, - configurationConnectorId: 'none', - configurationCustomFields: [], - configurationTemplateTags: [], + currentConfiguration: { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + templates: [], + mappings: [], + version: '', + id: '', + owner: mockedTestProvidersOwner[0], + }, }; beforeEach(() => { @@ -101,8 +114,12 @@ describe('form fields', () => { it('renders custom fields correctly', async () => { const newProps = { ...defaultProps, - configurationCustomFields: customFieldsConfigurationMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, }; + appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> <FormFields {...newProps} /> @@ -125,7 +142,15 @@ describe('form fields', () => { it('renders connector and its fields correctly', async () => { const newProps = { ...defaultProps, - configurationConnectorId: 'servicenow-1', + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, }; appMockRenderer.render( @@ -234,8 +259,12 @@ describe('form fields', () => { it('calls onSubmit with custom fields', async () => { const newProps = { ...defaultProps, - configurationCustomFields: customFieldsConfigurationMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, }; + appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> <FormFields {...newProps} /> @@ -282,7 +311,15 @@ describe('form fields', () => { it('calls onSubmit with connector fields', async () => { const newProps = { ...defaultProps, - configurationConnectorId: 'servicenow-1', + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }, }; appMockRenderer.render( diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index c0d93d0beedffd..6a063f5710a172 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -21,19 +21,19 @@ import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; interface FormFieldsProps { isSubmitting?: boolean; connectors: ActionConnector[]; - configurationConnectorId: string; - configurationCustomFields: CasesConfigurationUI['customFields']; - configurationTemplateTags: string[]; + currentConfiguration: CasesConfigurationUI; } const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting = false, connectors, - configurationConnectorId, - configurationCustomFields, - configurationTemplateTags, + currentConfiguration, }) => { const { isSyncAlertsEnabled } = useCasesFeatures(); + const { customFields: configurationCustomFields, connector, templates } = currentConfiguration; + const configurationTemplateTags = templates + .map((template) => (template?.tags?.length ? template.tags : [])) + .flat(); const firstStep = useMemo( () => ({ @@ -78,12 +78,12 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ <Connector connectors={connectors} isLoading={isSubmitting} - configurationConnectorId={configurationConnectorId} + configurationConnectorId={connector.id} /> </div> ), }), - [connectors, configurationConnectorId, isSubmitting] + [connectors, connector, isSubmitting] ); const allSteps = useMemo( diff --git a/x-pack/plugins/cases/public/components/templates/schema.test.tsx b/x-pack/plugins/cases/public/components/templates/schema.test.tsx new file mode 100644 index 00000000000000..cb35e758d6f562 --- /dev/null +++ b/x-pack/plugins/cases/public/components/templates/schema.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseFormFieldsSchemaWithOptionalLabel } from './schema'; + +describe('Template schema', () => { + describe('CaseFormFieldsSchemaWithOptionalLabel', () => { + it('has label append for each field', () => { + expect(CaseFormFieldsSchemaWithOptionalLabel).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "category": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "description": Object { + "label": "Description", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + "severity": Object { + "label": "Severity", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "tags": Object { + "helpText": "Separate tags with a line break.", + "label": "Tags", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "validator": [Function], + }, + ], + }, + "title": Object { + "label": "Name", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 7bc02f8a3cd288..bddecc8c36966f 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -9,23 +9,34 @@ import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { - MAX_DESCRIPTION_LENGTH, - MAX_LENGTH_PER_TAG, - MAX_TAGS_PER_CASE, MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATE_TAG_LENGTH, - MAX_TITLE_LENGTH, MAX_TEMPLATE_NAME_LENGTH, MAX_TEMPLATE_DESCRIPTION_LENGTH, } from '../../../common/constants'; import { OptionalFieldLabel } from '../create/optional_field_label'; -import { SEVERITY_TITLE } from '../severity/translations'; import * as i18n from './translations'; import type { TemplateFormProps } from './types'; -import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; - +import { + validateEmptyTags, + validateMaxLength, + validateMaxTagsLength, +} from '../case_form_fields/utils'; +import { schema as CaseFormFieldsSchema } from '../case_form_fields/schema'; const { emptyField, maxLengthField } = fieldValidators; +// add optional label to all case form fields +export const CaseFormFieldsSchemaWithOptionalLabel = Object.fromEntries( + Object.entries(CaseFormFieldsSchema).map(([key, value]) => { + if (typeof value === 'object') { + const updatedValue = { ...value, labelAppend: OptionalFieldLabel }; + return [key, updatedValue]; + } + + return [key, value]; + }) +); + export const schema: FormSchema<TemplateFormProps> = { key: { validations: [ @@ -50,6 +61,7 @@ export const schema: FormSchema<TemplateFormProps> = { }, templateDescription: { label: i18n.DESCRIPTION, + labelAppend: OptionalFieldLabel, validations: [ { validator: maxLengthField({ @@ -90,71 +102,6 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, - title: { - label: i18n.NAME, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: maxLengthField({ - length: MAX_TITLE_LENGTH, - message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), - }), - }, - ], - }, - description: { - label: i18n.DESCRIPTION, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: maxLengthField({ - length: MAX_DESCRIPTION_LENGTH, - message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), - }), - }, - ], - }, - tags: { - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: ({ value }: { value: string | string[] }) => - validateEmptyTags({ value, message: i18n.TAGS_EMPTY_ERROR }), - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string | string[] }) => - validateMaxLength({ - value, - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - limit: MAX_LENGTH_PER_TAG, - }), - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string[] }) => - validateMaxTagsLength({ - value, - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - limit: MAX_TAGS_PER_CASE, - }), - }, - ], - }, - severity: { - label: SEVERITY_TITLE, - labelAppend: OptionalFieldLabel, - }, - assignees: { - labelAppend: OptionalFieldLabel, - }, - category: { - labelAppend: OptionalFieldLabel, - }, connectorId: { labelAppend: OptionalFieldLabel, label: i18n.CONNECTORS, @@ -168,4 +115,5 @@ export const schema: FormSchema<TemplateFormProps> = { labelAppend: OptionalFieldLabel, defaultValue: true, }, + ...CaseFormFieldsSchemaWithOptionalLabel, }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts index 00de799ce431d1..35ea896753a0f4 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -7,133 +7,135 @@ import { templateSerializer, removeEmptyFields } from './utils'; -describe('templateSerializer', () => { - it('serializes empty fields correctly', () => { - const res = templateSerializer({ - key: '', - name: '', - templateDescription: '', - title: '', - description: '', - templateTags: [], - tags: [], - fields: null, - category: null, - }); - - expect(res).toEqual({ fields: null }); - }); +describe('utils', () => { + describe('templateSerializer', () => { + it('serializes empty fields correctly', () => { + const res = templateSerializer({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + category: null, + }); - it('serializes connectors fields correctly', () => { - const res = templateSerializer({ - key: '', - name: '', - templateDescription: '', - fields: null, + expect(res).toEqual({ fields: null }); }); - expect(res).toEqual({ - fields: null, - }); - }); + it('serializes connectors fields correctly', () => { + const res = templateSerializer({ + key: '', + name: '', + templateDescription: '', + fields: null, + }); - it('serializes non empty fields correctly', () => { - const res = templateSerializer({ - key: 'key_1', - name: 'template 1', - templateDescription: 'description 1', - templateTags: ['sample'], - category: 'new', + expect(res).toEqual({ + fields: null, + }); }); - expect(res).toEqual({ - key: 'key_1', - name: 'template 1', - templateDescription: 'description 1', - category: 'new', - templateTags: ['sample'], - fields: null, - }); - }); + it('serializes non empty fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + templateTags: ['sample'], + category: 'new', + }); - it('serializes custom fields correctly', () => { - const res = templateSerializer({ - key: 'key_1', - name: 'template 1', - templateDescription: '', - customFields: { - custom_field_1: 'foobar', - custom_fields_2: '', - custom_field_3: true, - }, + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + category: 'new', + templateTags: ['sample'], + fields: null, + }); }); - expect(res).toEqual({ - key: 'key_1', - name: 'template 1', - customFields: { - custom_field_1: 'foobar', - custom_field_3: true, - }, - fields: null, - }); - }); + it('serializes custom fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: '', + customFields: { + custom_field_1: 'foobar', + custom_fields_2: '', + custom_field_3: true, + }, + }); - it('serializes connector fields correctly', () => { - const res = templateSerializer({ - key: 'key_1', - name: 'template 1', - templateDescription: '', - fields: { - impact: 'high', - severity: 'low', - category: null, - urgency: null, - subcategory: null, - }, + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + customFields: { + custom_field_1: 'foobar', + custom_field_3: true, + }, + fields: null, + }); }); - expect(res).toEqual({ - key: 'key_1', - name: 'template 1', - fields: { - impact: 'high', - severity: 'low', - category: null, - urgency: null, - subcategory: null, - }, - }); - }); -}); + it('serializes connector fields correctly', () => { + const res = templateSerializer({ + key: 'key_1', + name: 'template 1', + templateDescription: '', + fields: { + impact: 'high', + severity: 'low', + category: null, + urgency: null, + subcategory: null, + }, + }); -describe('removeEmptyFields', () => { - it('removes empty fields', () => { - const res = removeEmptyFields({ - key: '', - name: '', - templateDescription: '', - title: '', - description: '', - templateTags: [], - tags: [], - fields: null, + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + fields: { + impact: 'high', + severity: 'low', + category: null, + urgency: null, + subcategory: null, + }, + }); }); - - expect(res).toEqual({}); }); - it('does not remove not empty fields', () => { - const res = removeEmptyFields({ - key: 'key_1', - name: 'template 1', - templateDescription: 'description 1', + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); }); - expect(res).toEqual({ - key: 'key_1', - name: 'template 1', - templateDescription: 'description 1', + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); }); }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 9ebe6ac19b2d9d..65262b7cefaa42 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -36,59 +36,3 @@ export const templateSerializer = (data: TemplateFormProps): TemplateFormProps = return data; }; - -const isInvalidTag = (value: string) => value.trim() === ''; - -const isTagCharactersInLimit = (value: string, limit: number) => value.trim().length > limit; - -export const validateEmptyTags = ({ - value, - message, -}: { - value: string | string[]; - message: string; -}) => { - if ( - (!Array.isArray(value) && isInvalidTag(value)) || - (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) - ) { - return { - message, - }; - } -}; - -export const validateMaxLength = ({ - value, - message, - limit, -}: { - value: string | string[]; - message: string; - limit: number; -}) => { - if ( - (!Array.isArray(value) && value.trim().length > limit) || - (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) - ) { - return { - message, - }; - } -}; - -export const validateMaxTagsLength = ({ - value, - message, - limit, -}: { - value: string | string[]; - message: string; - limit: number; -}) => { - if (Array.isArray(value) && value.length > limit) { - return { - message, - }; - } -}; From 95a77b491a3759b188137c95724129caec02a8b9 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 7 Jun 2024 16:00:16 +0100 Subject: [PATCH 27/28] typo fixed --- .../public/components/case_form_fields/custom_fields.test.tsx | 2 -- .../plugins/cases/public/components/configure_cases/index.tsx | 2 +- .../cases/public/components/configure_cases/translations.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) 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 9093a6f6a09c4d..2d11e8c73236ab 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 @@ -103,8 +103,6 @@ describe('CustomFields', () => { }); it('should update the custom fields', async () => { - // appMockRender = createAppMockRenderer(); - appMockRender.render( <FormTestComponent onSubmit={onSubmit}> <CustomFields {...defaultProps} /> 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 a317c05b5d2787..51346cbddc09c0 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -454,7 +454,7 @@ export const ConfigureCases: React.FC = React.memo(() => { } onCloseFlyout={onCloseTemplateFlyout} onSaveField={onTemplateSave} - renderHeader={() => <span>{i18n.CRATE_TEMPLATE}</span>} + renderHeader={() => <span>{i18n.CREATE_TEMPLATE}</span>} renderBody={({ onChange }) => ( <TemplateForm initialValue={templateToEdit as TemplateFormProps | null} diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index ec7181a766362d..08c83c9564f1e9 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -168,6 +168,6 @@ export const ADD_CUSTOM_FIELD = i18n.translate( } ); -export const CRATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.flyoutTitle', { +export const CREATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.flyoutTitle', { defaultMessage: 'Create template', }); From dc64cc82faaa837cafb1852b0794781c86d64287 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Fri, 7 Jun 2024 16:07:02 +0100 Subject: [PATCH 28/28] test update --- .../cases/public/components/configure_cases/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 aa4c6395e5c73c..6b5e251051e371 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 @@ -858,7 +858,7 @@ describe('ConfigureCases', () => { expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent( - i18n.CRATE_TEMPLATE + i18n.CREATE_TEMPLATE ); expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); });