From d6e897b129f0b9e238a2b4d9c9032f8887899078 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 10 Jun 2024 12:04:41 +0100 Subject: [PATCH 01/11] initial commit --- .../components/case_form_fields/index.tsx | 10 ++- .../delete_confirmation_modal.test.tsx | 53 ++++++++++++++ .../delete_confirmation_modal.tsx | 42 +++++++++++ .../components/configure_cases/index.test.tsx | 2 +- .../components/configure_cases/index.tsx | 40 ++++++++++- .../public/components/create/assignees.tsx | 16 ++++- .../cases/public/components/create/tags.tsx | 13 +++- .../custom_fields_list/index.test.tsx | 10 +-- .../custom_fields_list/index.tsx | 5 +- .../public/components/templates/form.tsx | 6 +- .../public/components/templates/index.tsx | 25 ++++++- .../components/templates/template_fields.tsx | 70 +++++++++++-------- .../components/templates/template_tags.tsx | 17 +++-- .../components/templates/templates_list.tsx | 59 ++++++++++++++-- .../components/templates/translations.ts | 10 +++ .../public/components/templates/types.ts | 2 +- .../public/components/templates/utils.ts | 47 ++++++++++++- 17 files changed, 366 insertions(+), 61 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx create mode 100644 x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx 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 d47929e676182..c3d6e6113fd8d 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,6 +7,7 @@ import React, { memo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; +import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Title } from '../create/title'; import { Tags } from '../create/tags'; import { Category } from '../create/category'; @@ -29,14 +30,19 @@ const CaseFormFieldsComponent: React.FC = ({ setCustomFieldsOptional = false, }) => { const { caseAssignmentAuthorized } = useCasesFeatures(); + const [{ assignees, tags }] = useFormData({ + watch: ['assignees', 'tags'], + }); return ( - {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} + {caseAssignmentAuthorized ? ( + <Assignees currentAssignees={assignees} isLoading={isLoading} /> + ) : null} - <Tags isLoading={isLoading} /> + <Tags isLoading={isLoading} currentTags={tags} /> <Category isLoading={isLoading} /> diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx new file mode 100644 index 0000000000000..ce46d368a5d2e --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.test.tsx @@ -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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; + +describe('DeleteConfirmationModal', () => { + let appMock: AppMockRenderer; + const props = { + title: 'My custom field', + message: 'This is a sample message', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(<DeleteConfirmationModal {...props} />); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..a994c8720cc17 --- /dev/null +++ b/x-pack/plugins/cases/public/components/configure_cases/delete_confirmation_modal.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from '../custom_fields/translations'; + +interface ConfirmDeleteCaseModalProps { + title: string; + message: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC<ConfirmDeleteCaseModalProps> = ({ + title, + message, + onCancel, + onConfirm, +}) => { + return ( + <EuiConfirmModal + buttonColor="danger" + cancelButtonText={i18n.CANCEL} + data-test-subj="confirm-delete-modal" + defaultFocusedButton="confirm" + onCancel={onCancel} + onConfirm={onConfirm} + title={title} + confirmButtonText={i18n.DELETE} + > + {message} + </EuiConfirmModal> + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); 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 6b5e251051e37..bc54e93b9851a 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 @@ -691,7 +691,7 @@ describe('ConfigureCases', () => { within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(screen.getByText('Delete')); 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 51346cbddc09c..1f6a4e966a7fc 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -356,6 +356,42 @@ export const ConfigureCases: React.FC = React.memo(() => { ] ); + const onDeleteTemplate = useCallback( + (key: string) => { + const remainingTemplates = templates.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + customFields, + templates: [...remainingTemplates], + id: configurationId, + version: configurationVersion, + closureType, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const onEditTemplate = useCallback( + (key: string) => { + const selectedTemplate = templates.find((item) => item.key === key); + + if (selectedTemplate) { + setTemplateToEdit(selectedTemplate); + } + setFlyOutVisibility({ type: 'template', visible: true }); + }, + [setFlyOutVisibility, setTemplateToEdit, templates] + ); + const onCloseTemplateFlyout = useCallback(() => { setFlyOutVisibility({ type: 'template', visible: false }); setTemplateToEdit(null); @@ -457,7 +493,7 @@ export const ConfigureCases: React.FC = React.memo(() => { renderHeader={() => <span>{i18n.CREATE_TEMPLATE}</span>} renderBody={({ onChange }) => ( <TemplateForm - initialValue={templateToEdit as TemplateFormProps | null} + initialValue={templateToEdit} connectors={connectors ?? []} currentConfiguration={currentConfiguration} onChange={onChange} @@ -550,6 +586,8 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + handleEditTemplate={onEditTemplate} + handleDeleteTemplate={onDeleteTemplate} /> </EuiFlexItem> </div> diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index 1e8464dc1a2ed..a9dfe624aeb08 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -35,9 +35,11 @@ import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../../utils/permissions'; import { useIsUserTyping } from '../../common/use_is_user_typing'; +import type { Assignee } from '../user_profiles/types'; interface Props { isLoading: boolean; + currentAssignees?: Assignee[]; } interface FieldProps { @@ -200,9 +202,13 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( AssigneesFieldComponent.displayName = 'AssigneesFieldComponent'; -const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { +const AssigneesComponent: React.FC<Props> = ({ + isLoading: isLoadingForm, + currentAssignees = [], +}) => { const { owner: owners } = useCasesContext(); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); + const [searchTerm, setSearchTerm] = useState(''); const [selectedOptions, setSelectedOptions] = useState<EuiComboBoxOptionOption[]>(); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); @@ -226,6 +232,14 @@ const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { userProfileToComboBoxOption(userProfile) ) ?? []; + const currentSelectedOptions = options.filter((option) => + currentAssignees.find((assignee) => assignee.uid === option.key) + ); + + if (currentSelectedOptions.length && !selectedOptions) { + setSelectedOptions(currentSelectedOptions); + } + const onSearchComboChange = (value: string) => { if (!isEmpty(value)) { setSearchTerm(value); diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index e08f9ace8dde0..8b8b2fce10bc2 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -11,11 +11,13 @@ 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'; +import { schema } from './schema'; interface Props { isLoading: boolean; + currentTags: string[]; } -const TagsComponent: React.FC<Props> = ({ isLoading }) => { +const TagsComponent: React.FC<Props> = ({ isLoading, currentTags }) => { const { data: tagOptions = [], isLoading: isLoadingTags } = useGetTags(); const options = useMemo( () => @@ -25,17 +27,22 @@ const TagsComponent: React.FC<Props> = ({ isLoading }) => { [tagOptions] ); + const tagsConfig = { + ...schema.tags, + defaultValue: currentTags ?? [], + }; + return ( <UseField path="tags" component={ComboBoxField} - defaultValue={[]} + config={tagsConfig} componentProps={{ idAria: 'caseTags', 'data-test-subj': 'caseTags', euiFieldProps: { + placeHolder: '', fullWidth: true, - placeholder: '', disabled: isLoading || isLoadingTags, options, noSuggestions: false, diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx index 002d3e65b4e61..fab80347300d0 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -99,7 +99,7 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); }); it('calls onDeleteCustomField when confirm', async () => { @@ -113,12 +113,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Delete')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).toHaveBeenCalledWith( customFieldsConfigurationMock[0].key ); @@ -136,12 +136,12 @@ describe('CustomFieldsList', () => { ) ); - expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); userEvent.click(await screen.findByText('Cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); expect(props.onDeleteCustomField).not.toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx index cfccb53e48db3..f8475a90b94ad 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx @@ -20,7 +20,7 @@ import * as i18n from '../translations'; import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; import { builderMap } from '../builder'; -import { DeleteConfirmationModal } from '../delete_confirmation_modal'; +import { DeleteConfirmationModal } from '../../configure_cases/delete_confirmation_modal'; export interface Props { customFields: CustomFieldsConfiguration; @@ -111,7 +111,8 @@ const CustomFieldsListComponent: React.FC<Props> = (props) => { </EuiFlexItem> {showModal && selectedItem ? ( <DeleteConfirmationModal - label={selectedItem.label} + title={i18n.DELETE_FIELD_TITLE(selectedItem.label)} + message={i18n.DELETE_FIELD_DESCRIPTION} onCancel={onCancel} onConfirm={onConfirm} /> diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index e7ca2451bb179..bc35abb1afb56 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -9,12 +9,12 @@ import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_l import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; import type { ActionConnector } from '../../../common/types/domain'; +import type { FormState } from '../configure_cases/flyout'; import { schema } from './schema'; import { FormFields } from './form_fields'; -import { templateSerializer } from './utils'; +import { templateDeserializer, templateSerializer } from './utils'; import type { TemplateFormProps } from './types'; import type { CasesConfigurationUI } from '../../containers/types'; -import type { FormState } from '../configure_cases/flyout'; interface Props { onChange: (state: FormState<TemplateFormProps>) => void; @@ -37,10 +37,12 @@ const FormComponent: React.FC<Props> = ({ name: '', templateDescription: '', templateTags: [], + tags: [], }, options: { stripEmptyFields: false }, schema, serializer: templateSerializer, + deserializer: templateDeserializer, }); const { submit, isValid, isSubmitting } = form; diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index f86037ce97813..ee63536d29ce5 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -27,9 +27,18 @@ interface Props { isLoading: boolean; templates: CasesConfigurationUITemplate[]; onAddTemplate: () => void; + handleEditTemplate: (key: string) => void; + handleDeleteTemplate: (key: string) => void; } -const TemplatesComponent: React.FC<Props> = ({ disabled, isLoading, templates, onAddTemplate }) => { +const TemplatesComponent: React.FC<Props> = ({ + disabled, + isLoading, + templates, + onAddTemplate, + handleEditTemplate, + handleDeleteTemplate, +}) => { const { permissions } = useCasesContext(); const canAddTemplates = permissions.create && permissions.update; const [error, setError] = useState<boolean>(false); @@ -44,6 +53,14 @@ const TemplatesComponent: React.FC<Props> = ({ disabled, isLoading, templates, o setError(false); }, [onAddTemplate, error, templates]); + const onEditTemplate = useCallback( + (key: string) => { + setError(false); + handleEditTemplate(key); + }, + [setError, handleEditTemplate] + ); + return ( <EuiDescribedFormGroup fullWidth @@ -61,7 +78,11 @@ const TemplatesComponent: React.FC<Props> = ({ disabled, isLoading, templates, o <EuiPanel paddingSize="s" color="subdued" hasBorder={false} hasShadow={false}> {templates.length ? ( <> - <TemplatesList templates={templates} /> + <TemplatesList + templates={templates} + onEditTemplate={onEditTemplate} + onDeleteTemplate={handleDeleteTemplate} + /> {error ? ( <EuiFlexGroup justifyContent="center"> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx index e9c34af53ca69..78faf9718c3c7 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { UseField } 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 { TextField, TextAreaField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiFlexGroup } from '@elastic/eui'; import { OptionalFieldLabel } from '../create/optional_field_label'; @@ -15,35 +15,45 @@ 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> -); +}> = ({ isLoading = false, configurationTemplateTags }) => { + const [{ templateTags }] = useFormData({ + watch: ['templateTags'], + }); + + return ( + <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} + tagOptions={configurationTemplateTags} + currentTags={templateTags} + /> + <UseField + path="templateDescription" + component={TextAreaField} + componentProps={{ + labelAppend: OptionalFieldLabel, + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + isLoading, + }, + }} + /> + </EuiFlexGroup> + ); +}; TemplateFieldsComponent.displayName = 'TemplateFields'; diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx index 16c37154a650f..8f1143e4b4b0e 100644 --- a/x-pack/plugins/cases/public/components/templates/template_tags.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_tags.tsx @@ -10,27 +10,34 @@ 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'; +import { schema } from './schema'; interface Props { isLoading: boolean; - tags: string[]; + currentTags: string[]; + tagOptions: string[]; } -const TemplateTagsComponent: React.FC<Props> = ({ isLoading, tags }) => { - const options = tags.map((label) => ({ +const TemplateTagsComponent: React.FC<Props> = ({ isLoading, currentTags, tagOptions }) => { + const options = tagOptions.map((label) => ({ label, })); + const templateTagsConfig = { + ...schema.templateTags, + defaultValue: currentTags ?? [], + }; + return ( <UseField path="templateTags" component={ComboBoxField} - defaultValue={[]} + config={templateTagsConfig} componentProps={{ idAria: 'template-tags', 'data-test-subj': 'template-tags', euiFieldProps: { - fullWidth: true, placeholder: '', + fullWidth: true, disabled: isLoading, isLoading, options, 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 999b02edf32a1..96ea67932bd8b 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiPanel, EuiFlexGroup, @@ -14,18 +14,37 @@ import { EuiText, EuiBadge, useEuiTheme, + EuiButtonIcon, } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { CasesConfigurationUITemplate } from '../../../common/ui'; import { TruncatedText } from '../truncated_text'; - +import type { TemplateConfiguration, TemplatesConfiguration } from '../../../common/types/domain'; +import { DeleteConfirmationModal } from '../configure_cases/delete_confirmation_modal'; +import * as i18n from './translations'; export interface Props { - templates: CasesConfigurationUITemplate[]; + templates: TemplatesConfiguration; + onDeleteTemplate: (key: string) => void; + onEditTemplate: (key: string) => void; } const TemplatesListComponent: React.FC<Props> = (props) => { - const { templates } = props; + const { templates, onEditTemplate, onDeleteTemplate } = props; const { euiTheme } = useEuiTheme(); + const [selectedItem, setSelectedItem] = useState<TemplateConfiguration | null>(null); + + const onConfirm = useCallback(() => { + if (selectedItem) { + onDeleteTemplate(selectedItem.key); + } + + setSelectedItem(null); + }, [onDeleteTemplate, setSelectedItem, selectedItem]); + + const onCancel = useCallback(() => { + setSelectedItem(null); + }, []); + + const showModal = Boolean(selectedItem); return templates.length ? ( <> @@ -65,12 +84,42 @@ const TemplatesListComponent: React.FC<Props> = (props) => { : null} </EuiFlexGroup> </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="flexEnd" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-edit`} + aria-label={`${template.key}-template-edit`} + iconType="pencil" + color="primary" + onClick={() => onEditTemplate(template.key)} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj={`${template.key}-template-delete`} + aria-label={`${template.key}-template-delete`} + iconType="minusInCircle" + color="danger" + onClick={() => setSelectedItem(template)} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> </EuiFlexGroup> </EuiPanel> <EuiSpacer size="s" /> </React.Fragment> ))} </EuiFlexItem> + {showModal && selectedItem ? ( + <DeleteConfirmationModal + title={i18n.DELETE_TITLE(selectedItem.name)} + message={i18n.DELETE_MESSAGE(selectedItem.name)} + 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 c48d3bce328f7..d005a6f4a3fa3 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -65,6 +65,16 @@ export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorF defaultMessage: 'External Connector Fields', }); +export const DELETE_TITLE = (name: string) => + i18n.translate('xpack.cases.templates.deleteTitle', { + defaultMessage: `Delete ${name} ?`, + }); + +export const DELETE_MESSAGE = (name: string) => + i18n.translate('xpack.cases.templates.deleteMessage', { + defaultMessage: `This action will permanently delete ${name}.`, + }); + export const MAX_TEMPLATE_LIMIT = (maxTemplates: number) => i18n.translate('xpack.cases.templates.maxTemplateLimit', { values: { maxTemplates }, diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index 38fe786e52f57..eba31a80ebe4e 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -24,5 +24,5 @@ export type CaseFieldsProps = Omit< export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & CaseFieldsProps & { templateTags?: string[]; - templateDescription: 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 65262b7cefaa4..5798cdef3a982 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -6,7 +6,9 @@ */ import { isEmpty } from 'lodash'; -import { getConnectorsFormSerializer } from '../utils'; +import type { TemplateConfiguration } from '../../../common/types/domain'; +import type { CaseUI } from '../../containers/types'; +import { getConnectorsFormDeserializer, getConnectorsFormSerializer } from '../utils'; import type { TemplateFormProps } from './types'; export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { @@ -22,6 +24,49 @@ export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Pa ) as T; } +export const convertTemplateCustomFields = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const templateDeserializer = (data: TemplateConfiguration): TemplateFormProps => { + if (data !== null) { + console.log('templateDeserializer 1', { data }); + const { key, name, description, tags, caseFields } = data; + const { connector, customFields, settings, ...rest } = caseFields ?? {}; + const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); + const convertedCustomFields = convertTemplateCustomFields(customFields); + + const temp = { + key, + name, + templateDescription: description ?? '', + templateTags: tags, + connectorId: connector?.id ?? 'none', + fields: connectorFields.fields, + customFields: convertedCustomFields ?? {}, + ...rest, + }; + + console.log('templateDeserializer 2', temp); + + return temp; + } + + return data; +}; + export const templateSerializer = (data: TemplateFormProps): TemplateFormProps => { if (data !== null) { const { fields = null, ...rest } = data; From 40925292ea646034dc7870756610273846ba33e2 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 10 Jun 2024 18:14:58 +0100 Subject: [PATCH 02/11] edit connector data --- .../components/configure_cases/index.tsx | 5 +++-- .../public/components/templates/connector.tsx | 10 ++++++---- .../cases/public/components/templates/form.tsx | 3 +++ .../components/templates/form_fields.tsx | 15 ++++++++------- .../public/components/templates/index.tsx | 18 +++++++++--------- .../public/components/templates/schema.tsx | 1 - .../cases/public/components/templates/utils.ts | 9 ++------- 7 files changed, 31 insertions(+), 30 deletions(-) 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 1f6a4e966a7fc..e083c916e51cf 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -496,6 +496,7 @@ export const ConfigureCases: React.FC = React.memo(() => { initialValue={templateToEdit} connectors={connectors ?? []} currentConfiguration={currentConfiguration} + isEditMode={Boolean(templateToEdit)} onChange={onChange} /> )} @@ -586,8 +587,8 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} - handleEditTemplate={onEditTemplate} - handleDeleteTemplate={onDeleteTemplate} + onEditTemplate={onEditTemplate} + onDeleteTemplate={onDeleteTemplate} /> </EuiFlexItem> </div> diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 2886da2663333..800d685ca762c 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 } from 'react'; +import React, { memo, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; @@ -23,16 +23,16 @@ interface Props { connectors: ActionConnector[]; isLoading: boolean; configurationConnectorId: string; + isEditMode?: boolean; } const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, configurationConnectorId, + isEditMode = false, }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); - const connector = getConnectorById(connectorId, connectors) ?? null; - const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; @@ -42,6 +42,8 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, }); + const connector = useMemo(() => getConnectorById(connectorId, connectors) ?? null, [connectorId]); + if (!hasReadPermissions) { return ( <EuiText data-test-subj="create-case-connector-permissions-error-msg" size="s"> @@ -57,7 +59,7 @@ const ConnectorComponent: React.FC<Props> = ({ path="connectorId" config={connectorIdConfig} component={ConnectorSelector} - defaultValue={configurationConnectorId} + defaultValue={isEditMode ? connectorId : 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 bc35abb1afb56..b457994878464 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -21,6 +21,7 @@ interface Props { initialValue: TemplateFormProps | null; connectors: ActionConnector[]; currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; } const FormComponent: React.FC<Props> = ({ @@ -28,6 +29,7 @@ const FormComponent: React.FC<Props> = ({ initialValue, connectors, currentConfiguration, + isEditMode = false, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -59,6 +61,7 @@ const FormComponent: React.FC<Props> = ({ isSubmitting={isSubmitting} connectors={connectors} currentConfiguration={currentConfiguration} + isEditMode={isEditMode} /> </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 6a063f5710a17..921ed828bd2e1 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -22,12 +22,14 @@ interface FormFieldsProps { isSubmitting?: boolean; connectors: ActionConnector[]; currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; } const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isSubmitting = false, connectors, currentConfiguration, + isEditMode, }) => { const { isSyncAlertsEnabled } = useCasesFeatures(); const { customFields: configurationCustomFields, connector, templates } = currentConfiguration; @@ -74,13 +76,12 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ () => ({ title: i18n.CONNECTOR_FIELDS, children: ( - <div> - <Connector - connectors={connectors} - isLoading={isSubmitting} - configurationConnectorId={connector.id} - /> - </div> + <Connector + connectors={connectors} + isLoading={isSubmitting} + configurationConnectorId={connector.id} + isEditMode={isEditMode} + /> ), }), [connectors, connector, isSubmitting] diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index ee63536d29ce5..40717189d8581 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -27,8 +27,8 @@ interface Props { isLoading: boolean; templates: CasesConfigurationUITemplate[]; onAddTemplate: () => void; - handleEditTemplate: (key: string) => void; - handleDeleteTemplate: (key: string) => void; + onEditTemplate: (key: string) => void; + onDeleteTemplate: (key: string) => void; } const TemplatesComponent: React.FC<Props> = ({ @@ -36,8 +36,8 @@ const TemplatesComponent: React.FC<Props> = ({ isLoading, templates, onAddTemplate, - handleEditTemplate, - handleDeleteTemplate, + onEditTemplate, + onDeleteTemplate, }) => { const { permissions } = useCasesContext(); const canAddTemplates = permissions.create && permissions.update; @@ -53,12 +53,12 @@ const TemplatesComponent: React.FC<Props> = ({ setError(false); }, [onAddTemplate, error, templates]); - const onEditTemplate = useCallback( + const handleEditTemplate = useCallback( (key: string) => { setError(false); - handleEditTemplate(key); + onEditTemplate(key); }, - [setError, handleEditTemplate] + [setError, onEditTemplate] ); return ( @@ -80,8 +80,8 @@ const TemplatesComponent: React.FC<Props> = ({ <> <TemplatesList templates={templates} - onEditTemplate={onEditTemplate} - onDeleteTemplate={handleDeleteTemplate} + onEditTemplate={handleEditTemplate} + onDeleteTemplate={onDeleteTemplate} /> {error ? ( <EuiFlexGroup justifyContent="center"> diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index bddecc8c36966..7fd0347025dc1 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -105,7 +105,6 @@ export const schema: FormSchema<TemplateFormProps> = { connectorId: { labelAppend: OptionalFieldLabel, label: i18n.CONNECTORS, - defaultValue: 'none', }, fields: { defaultValue: null, diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 5798cdef3a982..dc36bb6024e19 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -42,26 +42,21 @@ export const convertTemplateCustomFields = ( export const templateDeserializer = (data: TemplateConfiguration): TemplateFormProps => { if (data !== null) { - console.log('templateDeserializer 1', { data }); const { key, name, description, tags, caseFields } = data; const { connector, customFields, settings, ...rest } = caseFields ?? {}; const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); const convertedCustomFields = convertTemplateCustomFields(customFields); - const temp = { + return { key, name, - templateDescription: description ?? '', + templateDescription: description, templateTags: tags, connectorId: connector?.id ?? 'none', fields: connectorFields.fields, customFields: convertedCustomFields ?? {}, ...rest, }; - - console.log('templateDeserializer 2', temp); - - return temp; } return data; From eaf57b7167d42e393f9dc932ccab2f45cfbf73f6 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 11 Jun 2024 18:21:32 +0100 Subject: [PATCH 03/11] add unit tests --- .../configure_cases/flyout.test.tsx | 165 +++++++++++++++++- .../components/configure_cases/index.test.tsx | 104 ++++++++++- .../public/components/connectors/constants.ts | 2 + .../connectors/jira/case_fields.test.tsx | 22 +++ .../connectors/jira/case_fields.tsx | 4 +- .../connectors/jira/search_issues.tsx | 16 +- .../connectors/jira/use_get_issue.test.tsx | 132 ++++++++++++++ .../connectors/jira/use_get_issue.tsx | 56 ++++++ .../cases/public/components/create/tags.tsx | 4 +- .../components/templates/connector.test.tsx | 49 +++++- .../public/components/templates/connector.tsx | 7 +- .../public/components/templates/form.tsx | 4 +- .../components/templates/form_fields.test.tsx | 32 +++- .../components/templates/form_fields.tsx | 2 +- .../components/templates/index.test.tsx | 38 +++- .../templates/template_fields.test.tsx | 69 +++++++- .../templates/template_tags.test.tsx | 47 ++++- .../templates/templates_list.test.tsx | 74 +++++++- 18 files changed, 803 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.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 index 4872c1af6f449..994349e1f2969 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,7 +11,11 @@ import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; -import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, @@ -29,8 +33,10 @@ import * as i18n from './translations'; import type { FlyOutBodyProps } from './flyout'; import { CommonFlyout } from './flyout'; import type { TemplateFormProps } from '../templates/types'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; jest.mock('../connectors/servicenow/use_get_choices'); +jest.mock('../../containers/user_profiles/api'); const useGetChoicesMock = useGetChoices as jest.Mock; @@ -386,6 +392,84 @@ describe('CommonFlyout ', () => { expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); }); + it('should render all fields with details', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + const newConfiguration = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={[]} + currentConfiguration={newConfiguration} + onChange={onChange} + /> + ); + + appMockRender = createAppMockRenderer({ license }); + + appMockRender.render( + <CommonFlyout + {...{ + ...newProps, + renderBody: newRenderBody, + }} + /> + ); + + // template fields + expect(await screen.findByTestId('template-name-input')).toHaveValue('Fourth test template'); + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a fourth test template' + ); + + const templateTags = await screen.findByTestId('template-tags'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('foo'); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent('bar'); + + const caseTitle = await screen.findByTestId('caseTitle'); + expect(within(caseTitle).getByTestId('input')).toHaveValue('Case with sample template 4'); + + const caseDescription = await screen.findByTestId('caseDescription'); + expect(within(caseDescription).getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( + 'case desc' + ); + + const caseCategory = await screen.findByTestId('caseCategory'); + expect(within(caseCategory).getByRole('combobox')).toHaveTextContent(''); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('sample-4'); + + expect(await screen.findByTestId('case-severity-selection-low')).toBeInTheDocument(); + + const assigneesComboBox = await screen.findByTestId('createCaseAssigneesComboBox'); + + expect(await within(assigneesComboBox).findByTestId('comboBoxInput')).toHaveTextContent( + 'Damaged Raccoon' + ); + + // custom fields + expect( + await screen.findByTestId('first_custom_field_key-text-create-custom-field') + ).toHaveValue('this is a text field value'); + + // connector + expect(await screen.findByTestId('dropdown-connector-no-connector')).toBeInTheDocument(); + }); + it('calls onSaveField form correctly', async () => { appMockRender.render(<CommonFlyout {...newProps} />); @@ -419,7 +503,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={[]} currentConfiguration={currentConfiguration} @@ -472,7 +556,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={[]} currentConfiguration={newConfig} @@ -530,7 +614,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={connectorsMock} currentConfiguration={newConfig} @@ -571,6 +655,79 @@ describe('CommonFlyout ', () => { }); }); + it('calls onSaveField with edited fields correctly', async () => { + const newConfig = { + ...currentConfiguration, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'First custom field', + required: true, + }, + ], + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, + }; + + const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={connectorsMock} + currentConfiguration={newConfig} + onChange={onChange} + isEditMode={true} + /> + ); + + appMockRender.render( + <CommonFlyout + {...{ + ...newProps, + renderBody: newRenderBody, + }} + /> + ); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); + + const caseTitle = await screen.findByTestId('caseTitle'); + userEvent.clear(within(caseTitle).getByTestId('input')); + userEvent.paste(within(caseTitle).getByTestId('input'), 'Updated case using template'); + + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + userEvent.clear(customField); + userEvent.paste(customField, 'Updated custom field value'); + + userEvent.click(await screen.findByTestId('common-flyout-save')); + + await waitFor(() => { + expect(newProps.onSaveField).toBeCalledWith({ + connectorId: 'none', + customFields: { + first_custom_field_key: 'Updated custom field value', + }, + description: 'case desc', + fields: null, + key: 'test_template_4', + name: 'Template name', + severity: 'low', + syncAlerts: true, + tags: ['sample-4'], + templateDescription: 'This is a fourth test template', + templateTags: ['foo', 'bar'], + title: 'Updated case using template', + }); + }); + }); + it('shows error when template name is empty', async () => { appMockRender.render(<CommonFlyout {...newProps} />); 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 bc54e93b9851a..c625fa6f1324e 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 @@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; -import { customFieldsConfigurationMock } from '../../containers/mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../../containers/mock'; import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -938,6 +938,108 @@ describe('ConfigureCases', () => { expect(screen.getByTestId('templates-form-group')).toBeInTheDocument(); expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); }); + + it('should delete a template', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: templatesConfigurationMock, + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [ + { ...templatesConfigurationMock[1] }, + { ...templatesConfigurationMock[2] }, + { ...templatesConfigurationMock[3] }, + ], + id: '', + version: '', + }); + }); + }); + + it('should update a template', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + templates: [templatesConfigurationMock[0], templatesConfigurationMock[3]], + }, + })); + + appMockRender.render(<ConfigureCases />); + + const list = screen.getByTestId('templates-list'); + + userEvent.click( + within(list).getByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + userEvent.clear(await screen.findByTestId('template-name-input')); + userEvent.paste(await screen.findByTestId('template-name-input'), 'Updated template name'); + + userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [ + { + ...templatesConfigurationMock[0], + name: 'Updated template name', + tags: [], + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + }, + { ...templatesConfigurationMock[3] }, + ], + id: '', + version: '', + }); + }); + }); }); describe('rendering with license limitations', () => { diff --git a/x-pack/plugins/cases/public/components/connectors/constants.ts b/x-pack/plugins/cases/public/components/connectors/constants.ts index 486698330d860..1443b6ae49b05 100644 --- a/x-pack/plugins/cases/public/components/connectors/constants.ts +++ b/x-pack/plugins/cases/public/components/connectors/constants.ts @@ -15,6 +15,8 @@ export const connectorsQueriesKeys = { [...connectorsQueriesKeys.jira, connectorId, 'getIssueType'] as const, jiraGetIssues: (connectorId: string, query: string) => [...connectorsQueriesKeys.jira, connectorId, 'getIssues', query] as const, + jiraGetIssue: (connectorId: string, id: string) => + [...connectorsQueriesKeys.jira, connectorId, 'getIssue', id] as const, resilientGetIncidentTypes: (connectorId: string) => [...connectorsQueriesKeys.resilient, connectorId, 'getIncidentTypes'] as const, resilientGetSeverity: (connectorId: string) => diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx index 743ecac4cdc91..d4311e5a6fcf8 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -13,6 +13,7 @@ import userEvent from '@testing-library/user-event'; import { connector, issues } from '../mock'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import { useGetIssue } from './use_get_issue'; import Fields from './case_fields'; import { useGetIssues } from './use_get_issues'; import type { AppMockRenderer } from '../../../common/mock'; @@ -22,11 +23,13 @@ import { MockFormWrapperComponent } from '../test_utils'; jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); jest.mock('./use_get_issues'); +jest.mock('./use_get_issue'); jest.mock('../../../common/lib/kibana'); const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const useGetIssuesMock = useGetIssues as jest.Mock; +const useGetIssueMock = useGetIssue as jest.Mock; describe('Jira Fields', () => { const useGetIssueTypesResponse = { @@ -84,6 +87,12 @@ describe('Jira Fields', () => { data: { data: issues }, }; + const useGetIssueResponse = { + isLoading: false, + isFetching: false, + data: { data: issues[0] }, + }; + let appMockRenderer: AppMockRenderer; beforeEach(() => { @@ -91,6 +100,7 @@ describe('Jira Fields', () => { useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); useGetIssuesMock.mockReturnValue(useGetIssuesResponse); + useGetIssueMock.mockReturnValue(useGetIssueResponse); jest.clearAllMocks(); }); @@ -237,6 +247,18 @@ describe('Jira Fields', () => { expect(await screen.findByTestId('prioritySelect')).toHaveValue('Low'); }); + it('sets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + }); + it('should submit Jira connector', async () => { appMockRenderer.render( <MockFormWrapperComponent fields={fields}> 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 57772c0b177b7..485135bcd3e4c 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 @@ -27,7 +27,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co const [{ fields }] = useFormData<{ fields: JiraFieldsType }>(); const { http } = useKibana().services; - const { issueType } = fields ?? {}; + const { issueType, parent } = fields ?? {}; const { isLoading: isLoadingIssueTypesData, @@ -107,7 +107,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co <div style={{ display: hasParent ? 'block' : 'none' }}> <EuiFlexGroup> <EuiFlexItem> - <SearchIssues actionConnector={connector} /> + <SearchIssues actionConnector={connector} currentParent={parent} /> </EuiFlexItem> </EuiFlexGroup> <EuiSpacer size="m" /> 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 27df975ac5864..2aead9d58afc8 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 @@ -17,12 +17,14 @@ import { useKibana } from '../../../common/lib/kibana'; import type { ActionConnector } from '../../../../common/types/domain'; import { useGetIssues } from './use_get_issues'; import * as i18n from './translations'; +import { useGetIssue } from './use_get_issue'; interface Props { actionConnector?: ActionConnector; + currentParent: string | null; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, currentParent }) => { const [query, setQuery] = useState<string | null>(null); const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( [] @@ -35,10 +37,22 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { query, }); + const { isFetching: isLoadingIssue, data: issueData } = useGetIssue({ + http, + actionConnector, + id: currentParent ?? '', + }); + const issues = issuesData?.data ?? []; const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); + const issue = issueData?.data ?? null; + + if (!isLoadingIssue && issue && !selectedOptions.find((option) => option.value === issue.key)) { + setSelectedOptions([{ label: issue.title, value: issue.key }]); + } + return ( <UseField path="fields.parent"> {(field) => { diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx new file mode 100644 index 0000000000000..7c1fd4e80dffc --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; + +import { useKibana, useToasts } from '../../../common/lib/kibana'; +import { connector as actionConnector } from '../mock'; +import { useGetIssue } from './use_get_issue'; +import * as api from './api'; +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; + +describe('useGetIssue', () => { + const { http } = useKibanaMock().services; + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'getIssue'); + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(spy).toHaveBeenCalledWith({ + http, + signal: expect.anything(), + connectorId: actionConnector.id, + id: 'RJ-107', + }); + }); + + it('does not call the api when the connector is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('does not call the api when the id is missing', async () => { + const spy = jest.spyOn(api, 'getIssue'); + renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: '', + }), + { wrapper: appMockRender.AppWrapper } + ); + + expect(spy).not.toHaveBeenCalledWith(); + }); + + it('calls addError when the getIssue api throws an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isError); + + expect(addError).toHaveBeenCalled(); + }); + + it('calls addError when the getIssue api returns successfully but contains an error', async () => { + const spyOnGetCases = jest.spyOn(api, 'getIssue'); + spyOnGetCases.mockResolvedValue({ + status: 'error', + message: 'Error message', + actionId: 'test', + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess: jest.fn(), addError }); + + const { result, waitFor } = renderHook( + () => + useGetIssue({ + http, + actionConnector, + id: 'RJ-107', + }), + { wrapper: appMockRender.AppWrapper } + ); + + await waitFor(() => result.current.isSuccess); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx new file mode 100644 index 0000000000000..ed3bfcf61f2f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.tsx @@ -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 type { HttpSetup } from '@kbn/core/public'; +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { useQuery } from '@tanstack/react-query'; +import { isEmpty } from 'lodash'; +import type { ActionConnector } from '../../../../common/types/domain'; +import { getIssue } from './api'; +import type { Issue } from './types'; +import * as i18n from './translations'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import type { ServerError } from '../../../types'; +import { connectorsQueriesKeys } from '../constants'; + +interface Props { + http: HttpSetup; + id: string; + actionConnector?: ActionConnector; +} + +export const useGetIssue = ({ http, actionConnector, id }: Props) => { + const { showErrorToast } = useCasesToast(); + return useQuery<ActionTypeExecutorResult<Issue>, ServerError>( + connectorsQueriesKeys.jiraGetIssue(actionConnector?.id ?? '', id), + ({ signal }) => { + return getIssue({ + http, + signal, + connectorId: actionConnector?.id ?? '', + id, + }); + }, + { + enabled: Boolean(actionConnector && !isEmpty(id)), + staleTime: 60 * 1000, // one minute + onSuccess: (res) => { + if (res.status && res.status === 'error') { + showErrorToast(new Error(i18n.GET_ISSUE_API_ERROR(id)), { + title: i18n.GET_ISSUE_API_ERROR(id), + toastMessage: `${res.serviceMessage ?? res.message}`, + }); + } + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.GET_ISSUE_API_ERROR(id) }); + }, + } + ); +}; + +export type UseGetIssueTypes = ReturnType<typeof useGetIssue>; diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index 8b8b2fce10bc2..ffb92fdf84b50 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -14,7 +14,7 @@ import * as i18n from './translations'; import { schema } from './schema'; interface Props { isLoading: boolean; - currentTags: string[]; + currentTags?: string[]; } const TagsComponent: React.FC<Props> = ({ isLoading, currentTags }) => { @@ -41,7 +41,7 @@ const TagsComponent: React.FC<Props> = ({ isLoading, currentTags }) => { idAria: 'caseTags', 'data-test-subj': 'caseTags', euiFieldProps: { - placeHolder: '', + placeholder: '', fullWidth: true, disabled: isLoading || isLoadingTags, options, 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 cc053f52a34f1..3222363d6afa4 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { connectorsMock } from '../../containers/mock'; @@ -96,6 +96,53 @@ describe('Connector', () => { expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); }); + it('renders existing connector correctly in edit mode', async () => { + appMockRender.render( + <FormTestComponent formDefaultValue={{ connectorId: connectorsMock[3].id }}> + <Connector {...{ ...defaultProps, configurationConnectorId: 'none', isEditMode: true }} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByText('My Connector SIR')).toBeInTheDocument(); + + expect(await screen.findByTestId('connector-fields-sn-sir')).toBeInTheDocument(); + }); + + it('calls on submit with existing connector over configuration connector in edit mode', async () => { + const onSubmit = jest.fn(); + + appMockRender.render( + <FormTestComponent + formDefaultValue={{ connectorId: connectorsMock[1].id }} + onSubmit={onSubmit} + > + <Connector + {...{ ...defaultProps, configurationConnectorId: connectorsMock[2].id, isEditMode: true }} + /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByText('My Resilient connector')).toBeInTheDocument(); + + expect(screen.queryByTestId('connector-fields-jira')).not.toBeInTheDocument(); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + connectorId: 'resilient-2', + fields: { + incidentTypes: [], + }, + }, + true + ); + }); + }); + it('shows all connectors in dropdown', async () => { appMockRender.render( <FormTestComponent> diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 800d685ca762c..2e793f0a4bd5d 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, useEffect, useMemo, useState } from 'react'; +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'; @@ -42,7 +42,10 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, }); - const connector = useMemo(() => getConnectorById(connectorId, connectors) ?? null, [connectorId]); + const connector = useMemo( + () => getConnectorById(connectorId, connectors) ?? null, + [connectorId, connectors] + ); if (!hasReadPermissions) { return ( diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index b457994878464..e86c4b6d68adf 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -37,9 +37,9 @@ const FormComponent: React.FC<Props> = ({ defaultValue: initialValue ?? { key: keyDefaultValue, name: '', - templateDescription: '', - templateTags: [], + description: '', tags: [], + caseFields: null, }, 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 103dcb9135b39..b10768d74c9ab 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,7 @@ 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 { ConnectorTypes } from '../../../common/types/domain'; +import { CaseSeverity, 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'; @@ -101,6 +101,36 @@ describe('form fields', () => { expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); }); + it('renders case fields with existing', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + title: 'Case title', + description: 'case description', + tags: ['case-1', 'case-2'], + category: 'new', + severity: CaseSeverity.MEDIUM, + }} + onSubmit={onSubmit} + > + <FormFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await within(await screen.findByTestId('caseTitle')).findByTestId('input')).toHaveValue( + 'Case title' + ); + + const caseTags = await screen.findByTestId('caseTags'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-1'); + expect(await within(caseTags).findByTestId('comboBoxInput')).toHaveTextContent('case-2'); + + const category = await screen.findByTestId('caseCategory'); + expect(await within(category).findByTestId('comboBoxSearchInput')).toHaveValue('new'); + expect(await screen.findByTestId('case-severity-selection-medium')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toHaveTextContent('case description'); + }); + it('renders sync alerts 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 921ed828bd2e1..1b4a2e1784f74 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -84,7 +84,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ /> ), }), - [connectors, connector, isSubmitting] + [connectors, connector, isSubmitting, isEditMode] ); const allSteps = useMemo( 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 075a4d2a2bd62..2745741fb87a7 100644 --- a/x-pack/plugins/cases/public/components/templates/index.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; @@ -25,6 +25,8 @@ describe('Templates', () => { isLoading: false, templates: [], onAddTemplate: jest.fn(), + onEditTemplate: jest.fn(), + onDeleteTemplate: jest.fn(), }; beforeEach(() => { @@ -74,6 +76,40 @@ describe('Templates', () => { expect(props.onAddTemplate).toBeCalled(); }); + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + expect(list).toBeInTheDocument(); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<Templates {...{ ...props, templates: templatesConfigurationMock }} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + it('shows the experimental badge', async () => { appMockRender.render(<Templates {...props} />); 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 index 5d0dd30da8e69..9638e0b93f5d4 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx @@ -38,6 +38,36 @@ describe('Template fields', () => { expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); }); + it('renders template fields with existing value', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-name-input')).toHaveValue('Sample template'); + + const templateTags = await screen.findByTestId('template-tags'); + + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-1' + ); + expect(await within(templateTags).findByTestId('comboBoxInput')).toHaveTextContent( + 'template-2' + ); + + expect(await screen.findByTestId('template-description-input')).toHaveTextContent( + 'This is a template description' + ); + }); + it('calls onSubmit with template fields', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> @@ -49,7 +79,7 @@ describe('Template fields', () => { const templateTags = await screen.findByTestId('template-tags'); - userEvent.paste(within(templateTags).getByRole('combobox'), 'first'); + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); userEvent.keyboard('{enter}'); userEvent.paste( @@ -70,4 +100,41 @@ describe('Template fields', () => { ); }); }); + + it('calls onSubmit with updated template fields', async () => { + appMockRenderer.render( + <FormTestComponent + formDefaultValue={{ + name: 'Sample template', + templateDescription: 'This is a template description', + templateTags: ['template-1', 'template-2'], + }} + onSubmit={onSubmit} + > + <TemplateFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.paste(await screen.findByTestId('template-name-input'), '!!'); + + const templateTags = await screen.findByTestId('template-tags'); + + userEvent.paste(await within(templateTags).findByRole('combobox'), 'first'); + userEvent.keyboard('{enter}'); + + userEvent.paste(await screen.findByTestId('template-description-input'), '..'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + name: 'Sample template!!', + templateDescription: 'This is a template description..', + templateTags: ['template-1', 'template-2', 'first'], + }, + true + ); + }); + }); }); 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 index 10ebc0e89e556..816a5462b4ffa 100644 --- a/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx @@ -27,7 +27,7 @@ describe('TemplateTags', () => { it('renders template tags', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tags={[]} /> + <TemplateTags isLoading={false} tagOptions={[]} currentTags={[]} /> </FormTestComponent> ); @@ -37,7 +37,7 @@ describe('TemplateTags', () => { it('renders loading state', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={true} tags={[]} /> + <TemplateTags isLoading={true} tagOptions={[]} currentTags={[]} /> </FormTestComponent> ); @@ -48,7 +48,7 @@ describe('TemplateTags', () => { it('shows template tags options', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tags={['foo', 'bar', 'test']} /> + <TemplateTags isLoading={false} tagOptions={['foo', 'bar', 'test']} currentTags={[]} /> </FormTestComponent> ); @@ -59,10 +59,24 @@ describe('TemplateTags', () => { expect(await screen.findByText('foo')).toBeInTheDocument(); }); + it('shows template tags with current values', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} currentTags={['foo', 'bar']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + expect(await screen.findByText('foo')).toBeInTheDocument(); + + expect(await screen.findByText('bar')).toBeInTheDocument(); + }); + it('adds template tag ', async () => { appMockRenderer.render( <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tags={[]} /> + <TemplateTags isLoading={false} tagOptions={[]} currentTags={[]} /> </FormTestComponent> ); @@ -85,4 +99,29 @@ describe('TemplateTags', () => { ); }); }); + + it('adds new template tag to existing tags', async () => { + appMockRenderer.render( + <FormTestComponent onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} currentTags={['foo', 'bar']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('template-tags')).toBeInTheDocument(); + + const comboBoxEle = await screen.findByRole('combobox'); + userEvent.paste(comboBoxEle, 'test'); + userEvent.keyboard('{enter}'); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + templateTags: ['foo', 'bar', 'test'], + }, + true + ); + }); + }); }); 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 e96d2b1b0befc..61f855c427c3c 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 @@ -6,18 +6,23 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } 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'; +import userEvent from '@testing-library/user-event'; describe('TemplatesList', () => { let appMockRender: AppMockRenderer; + const onDeleteTemplate = jest.fn(); + const onEditTemplate = jest.fn(); const props = { templates: templatesConfigurationMock, + onDeleteTemplate, + onEditTemplate, }; beforeEach(() => { @@ -70,4 +75,71 @@ describe('TemplatesList', () => { expect(screen.queryAllByTestId(`template-`, { exact: false })).toHaveLength(0); }); + + it('renders edit button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ).toBeInTheDocument(); + }); + + it('renders delete button', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ).toBeInTheDocument(); + }); + + it('renders delete modal', async () => { + appMockRender.render( + <TemplatesList {...{ ...props, templates: [templatesConfigurationMock[0]] }} /> + ); + + userEvent.click( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + expect(await screen.findByText('Delete')).toBeInTheDocument(); + expect(await screen.findByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onEditTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ); + + await waitFor(() => { + expect(props.onEditTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); + + it('calls onDeleteTemplate correctly', async () => { + appMockRender.render(<TemplatesList {...props} />); + + const list = await screen.findByTestId('templates-list'); + + userEvent.click( + await within(list).findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); + expect(props.onDeleteTemplate).toHaveBeenCalledWith(templatesConfigurationMock[0].key); + }); + }); }); From fe52101689767abe6808b5cc96dcfe0aa7e5c1a4 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:32:43 +0100 Subject: [PATCH 04/11] add more unit tests --- .../case_form_fields/index.test.tsx | 99 +++++++ .../configure_cases/flyout.test.tsx | 30 +- .../components/configure_cases/index.tsx | 4 +- .../public/components/templates/form.test.tsx | 266 +++++++++++++++--- .../public/components/templates/form.tsx | 4 +- .../components/templates/form_fields.test.tsx | 2 +- .../components/templates/translations.ts | 6 +- .../public/components/templates/utils.test.ts | 14 +- .../public/components/templates/utils.ts | 2 +- 9 files changed, 372 insertions(+), 55 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 3803a059b6db4..485882d82d38c 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 @@ -145,6 +145,36 @@ describe('CaseFormFields', () => { }); }); + it('calls onSubmit with existing case fields', async () => { + appMock.render( + <FormTestComponent + formDefaultValue={{ + title: 'Case with Template 1', + description: 'This is a case description', + tags: ['case-tag-1', 'case-tag-2'], + category: null, + }} + onSubmit={onSubmit} + > + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: ['case-tag-1', 'case-tag-2'], + description: 'This is a case description', + title: 'Case with Template 1', + }, + true + ); + }); + }); + it('calls onSubmit with custom fields', async () => { const newProps = { ...defaultProps, @@ -191,6 +221,43 @@ describe('CaseFormFields', () => { }); }); + it('calls onSubmit with existing custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + <FormTestComponent + formDefaultValue={{ + customFields: { [customFieldsConfigurationMock[0].key]: 'Test custom filed value' }, + }} + onSubmit={onSubmit} + > + <CaseFormFields {...newProps} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + customFields: { + test_key_1: 'Test custom filed value', + test_key_2: true, + test_key_4: false, + }, + }, + true + ); + }); + }); + it('calls onSubmit with assignees', async () => { const license = licensingMock.createLicense({ license: { type: 'platinum' }, @@ -225,4 +292,36 @@ describe('CaseFormFields', () => { ); }); }); + + it('calls onSubmit with existing assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + <FormTestComponent + formDefaultValue={{ + assignees: [{ uid: userProfiles[1].uid }], + }} + onSubmit={onSubmit} + > + <CaseFormFields {...defaultProps} /> + </FormTestComponent> + ); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { + category: null, + tags: [], + assignees: [{ uid: userProfiles[1].uid }], + }, + 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 994349e1f2969..33aa2ca42f57a 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 @@ -490,9 +490,13 @@ describe('CommonFlyout ', () => { name: 'Template name', templateDescription: 'Template description', templateTags: ['foo'], + title: '', + description: '', + tags: [], + severity: '', + category: null, connectorId: 'none', syncAlerts: true, - fields: null, }); }); }); @@ -539,12 +543,14 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', templateDescription: 'test description', + templateTags: [], title: 'Case using template', description: 'This is a case description', category: 'new', + tags: [], + severity: '', connectorId: 'none', syncAlerts: true, - fields: null, }); }); }); @@ -585,14 +591,20 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', templateDescription: 'test description', + templateTags: [], + title: '', + tags: [], + severity: '', + description: '', + category: null, connectorId: 'none', syncAlerts: true, customFields: { [customFieldsConfigurationMock[0].key]: 'this is a sample text!', [customFieldsConfigurationMock[1].key]: true, + [customFieldsConfigurationMock[2].key]: '', [customFieldsConfigurationMock[3].key]: false, }, - fields: null, }); }); }); @@ -642,12 +654,18 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', templateDescription: 'test description', + templateTags: [], + title: '', + tags: [], + severity: '', + description: '', + category: null, connectorId: 'servicenow-1', fields: { category: 'software', urgency: '1', - impact: null, - severity: null, + impact: '', + severity: '', subcategory: null, }, syncAlerts: true, @@ -715,7 +733,7 @@ describe('CommonFlyout ', () => { first_custom_field_key: 'Updated custom field value', }, description: 'case desc', - fields: null, + category: null, key: 'test_template_4', name: 'Template name', severity: 'low', 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 e083c916e51cf..c9719bb20af0b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -47,6 +47,7 @@ import { Templates } from '../templates'; import type { TemplateFormProps } from '../templates/types'; import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; +import { getTemplateSerializedData } from '../templates/utils'; const sectionWrapperCss = css` box-sizing: content-box; @@ -399,6 +400,7 @@ export const ConfigureCases: React.FC = React.memo(() => { const onTemplateSave = useCallback( (data: TemplateFormProps) => { + const serializedData = getTemplateSerializedData(data); const { connectorId, fields, @@ -409,7 +411,7 @@ export const ConfigureCases: React.FC = React.memo(() => { templateTags, templateDescription, ...otherCaseFields - } = data; + } = serializedData; const transformedCustomFields = templateCustomFields ? transformCustomFieldsData(templateCustomFields, customFields) 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 9b30f89f24713..3f4e52e53396a 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -17,7 +17,11 @@ import { MAX_TEMPLATE_TAG_LENGTH, } from '../../../common/constants'; import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; -import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { useGetChoicesResponse } from '../create/mock'; import type { FormState } from '../configure_cases/flyout'; @@ -83,7 +87,7 @@ describe('TemplateForm', () => { initialValue: { key: 'template_key_1', name: 'Template 1', - templateDescription: 'Sample description', + description: 'Sample description', }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -107,9 +111,11 @@ describe('TemplateForm', () => { initialValue: { key: 'template_key_1', name: 'Template 1', - templateDescription: 'Sample description', - title: 'Case with template 1', - description: 'case description', + description: 'Sample description', + caseFields: { + title: 'Case with template 1', + description: 'case description', + }, }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -173,9 +179,13 @@ describe('TemplateForm', () => { name: 'Template 1', templateDescription: 'this is a first template', templateTags: ['foo', 'bar'], + title: '', + description: '', + severity: '', + tags: [], connectorId: 'none', syncAlerts: true, - fields: null, + category: null, }); }); }); @@ -185,19 +195,20 @@ describe('TemplateForm', () => { const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); - appMockRenderer.render(<TemplateForm {...{ ...defaultProps, onChange: onChangeState }} />); + appMockRenderer.render( + <TemplateForm + {...{ + ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 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' - ); - const caseTitle = await screen.findByTestId('caseTitle'); userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); @@ -222,14 +233,55 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - templateDescription: 'this is a first template', + templateDescription: '', + templateTags: [], title: 'Case with Template 1', description: 'This is a case description', tags: ['template-1'], + severity: '', category: 'new', connectorId: 'none', syncAlerts: true, - fields: null, + }); + }); + }); + + it('serializes the case field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: templatesConfigurationMock[3], + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Fourth test template', + title: 'Case with sample template 4', + description: 'case desc', + templateDescription: 'This is a fourth test template', + tags: ['sample-4'], + connectorId: 'none', + severity: 'low', + syncAlerts: true, + category: null, + templateTags: ['foo', 'bar'], }); }); }); @@ -243,6 +295,7 @@ describe('TemplateForm', () => { <TemplateForm {...{ ...defaultProps, + initialValue: { key: 'template_1_key', name: 'Template 1' }, currentConfiguration: { ...defaultProps.currentConfiguration, connector: { @@ -265,13 +318,6 @@ describe('TemplateForm', () => { 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'); @@ -286,13 +332,90 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - templateDescription: 'this is a first template', + tags: [], + templateDescription: '', + templateTags: [], + title: '', + description: '', + category: null, + severity: '', connectorId: 'servicenow-1', fields: { category: 'software', urgency: '1', - impact: null, - severity: null, + impact: '', + severity: '', + subcategory: null, + }, + syncAlerts: true, + }); + }); + }); + + it('serializes the connector fields data correctly with existing connector', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + type: ConnectorTypes.serviceNowITSM, + name: 'my-SN-connector', + fields: null, + }, + }, + }, + connectors: connectorsMock, + currentConfiguration: { + ...defaultProps.currentConfiguration, + connector: { + id: 'resilient-2', + name: 'My Resilient connector', + type: ConnectorTypes.resilient, + fields: null, + }, + }, + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); + + userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['Denial of Service']); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'Template 1', + tags: [], + templateDescription: '', + templateTags: [], + title: '', + description: '', + category: null, + severity: '', + connectorId: 'servicenow-1', + fields: { + category: 'Denial of Service', + urgency: '', + impact: '', + severity: '', subcategory: null, }, syncAlerts: true, @@ -309,6 +432,10 @@ describe('TemplateForm', () => { <TemplateForm {...{ ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + }, currentConfiguration: { ...defaultProps.currentConfiguration, customFields: customFieldsConfigurationMock, @@ -322,13 +449,6 @@ describe('TemplateForm', () => { 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 customFieldsElement = await screen.findByTestId('caseCustomFields'); expect( @@ -360,15 +480,91 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), name: 'Template 1', - templateDescription: 'this is a first template', + tags: [], + templateDescription: '', + templateTags: [], + title: '', + description: '', + severity: '', + category: null, connectorId: 'none', syncAlerts: true, customFields: { test_key_1: 'My text test value 1', test_key_2: true, + test_key_3: '', test_key_4: true, }, - fields: null, + }); + }); + }); + + it('serializes the custom fields data correctly with existing custom fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { + key: 'template_1_key', + name: 'Template 1', + caseFields: { + customFields: [ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: 'this is my first custom field value', + }, + { + type: CustomFieldTypes.TOGGLE, + key: 'test_key_2', + value: false, + }, + ], + }, + }, + onChange: onChangeState, + currentConfiguration: { + ...defaultProps.currentConfiguration, + customFields: customFieldsConfigurationMock, + }, + }; + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const toggleField = customFieldsConfigurationMock[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', + tags: [], + templateDescription: '', + templateTags: [], + title: '', + description: '', + severity: '', + category: null, + connectorId: 'none', + syncAlerts: true, + customFields: { + test_key_1: 'this is my first custom field value', + test_key_2: true, + test_key_3: '', + test_key_4: false, + }, }); }); }); diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index e86c4b6d68adf..e35bc9ad2164d 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -12,7 +12,7 @@ import type { ActionConnector } from '../../../common/types/domain'; import type { FormState } from '../configure_cases/flyout'; import { schema } from './schema'; import { FormFields } from './form_fields'; -import { templateDeserializer, templateSerializer } from './utils'; +import { templateDeserializer } from './utils'; import type { TemplateFormProps } from './types'; import type { CasesConfigurationUI } from '../../containers/types'; @@ -43,7 +43,7 @@ const FormComponent: React.FC<Props> = ({ }, options: { stripEmptyFields: false }, schema, - serializer: templateSerializer, + // serializer: templateSerializer, deserializer: templateDeserializer, }); 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 b10768d74c9ab..3c513f85bd41d 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 @@ -101,7 +101,7 @@ describe('form fields', () => { expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); }); - it('renders case fields with existing', async () => { + it('renders case fields with existing value', async () => { appMockRenderer.render( <FormTestComponent formDefaultValue={{ diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index d005a6f4a3fa3..c800008b3d995 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -67,12 +67,14 @@ export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorF export const DELETE_TITLE = (name: string) => i18n.translate('xpack.cases.templates.deleteTitle', { - defaultMessage: `Delete ${name} ?`, + values: { name }, + defaultMessage: 'Delete {name} ?', }); export const DELETE_MESSAGE = (name: string) => i18n.translate('xpack.cases.templates.deleteMessage', { - defaultMessage: `This action will permanently delete ${name}.`, + values: { name }, + defaultMessage: 'This action will permanently delete {name}.', }); export const MAX_TEMPLATE_LIMIT = (maxTemplates: number) => 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 35ea896753a0f..fdbed01c54029 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { templateSerializer, removeEmptyFields } from './utils'; +import { getTemplateSerializedData, removeEmptyFields } from './utils'; describe('utils', () => { - describe('templateSerializer', () => { + describe('getTemplateSerializedData', () => { it('serializes empty fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: '', name: '', templateDescription: '', @@ -26,7 +26,7 @@ describe('utils', () => { }); it('serializes connectors fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: '', name: '', templateDescription: '', @@ -39,7 +39,7 @@ describe('utils', () => { }); it('serializes non empty fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: 'key_1', name: 'template 1', templateDescription: 'description 1', @@ -58,7 +58,7 @@ describe('utils', () => { }); it('serializes custom fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: 'key_1', name: 'template 1', templateDescription: '', @@ -81,7 +81,7 @@ describe('utils', () => { }); it('serializes connector fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: 'key_1', name: 'template 1', templateDescription: '', diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index dc36bb6024e19..dba25d16c96ed 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -62,7 +62,7 @@ export const templateDeserializer = (data: TemplateConfiguration): TemplateFormP return data; }; -export const templateSerializer = (data: TemplateFormProps): TemplateFormProps => { +export const getTemplateSerializedData = (data: TemplateFormProps): TemplateFormProps => { if (data !== null) { const { fields = null, ...rest } = data; const connectorFields = getConnectorsFormSerializer({ fields }); From 94e3a474745f954654a1531722749a264c8485ee Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:13:25 +0100 Subject: [PATCH 05/11] add e2e test --- .../public/components/create/tags.test.tsx | 100 ++++++++++-------- .../apps/cases/group2/configure.ts | 48 ++++++++- .../observability/cases/configure.ts | 48 ++++++++- .../security/ftr/cases/configure.ts | 48 ++++++++- 4 files changed, 195 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx index ed78d78928f0e..d6069371eb8d2 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -5,20 +5,16 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; import React from 'react'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -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 { Tags } from './tags'; -import type { FormProps } from './schema'; -import { schema } from './schema'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { MAX_LENGTH_PER_TAG } from '../../../common/constants'; +import { FormTestComponent } from '../../common/test_utils'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/use_get_tags'); @@ -26,25 +22,8 @@ jest.mock('../../containers/use_get_tags'); const useGetTagsMock = useGetTags as jest.Mock; describe('Tags', () => { - let globalForm: FormHook; let appMockRender: AppMockRenderer; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { tags: [] }, - schema: { - tags: schema.tags, - }, - }); - - globalForm = form; - - return ( - <TestProviders> - <Form form={form}>{children}</Form> - </TestProviders> - ); - }; + const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -54,59 +33,88 @@ describe('Tags', () => { it('it renders', async () => { appMockRender.render( - <MockHookWrapperComponent> + <FormTestComponent> <Tags isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); - await waitFor(() => { - expect(screen.getByTestId('caseTags')).toBeInTheDocument(); - }); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + }); + + it('it renders existing tags when provided', async () => { + appMockRender.render( + <FormTestComponent> + <Tags isLoading={false} currentTags={['foo', 'bar']} /> + </FormTestComponent> + ); + + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByText('foo')).toBeInTheDocument(); + expect(await screen.findByText('bar')).toBeInTheDocument(); }); it('it changes the tags', async () => { appMockRender.render( - <MockHookWrapperComponent> + <FormTestComponent onSubmit={onSubmit}> <Tags isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> + ); + + userEvent.type(await screen.findByRole('combobox'), 'test{enter}'); + userEvent.type(await screen.findByRole('combobox'), 'case{enter}'); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ tags: ['test', 'case'] }, true); + }); + }); + + it('it adds the tags to existing array', async () => { + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <Tags isLoading={false} currentTags={['foo', 'bar']} /> + </FormTestComponent> ); - userEvent.type(screen.getByRole('combobox'), 'test{enter}'); - userEvent.type(screen.getByRole('combobox'), 'case{enter}'); + userEvent.paste(await screen.findByRole('combobox'), 'dude'); + userEvent.keyboard('{enter}'); + + userEvent.click(await screen.findByText('Submit')); - expect(globalForm.getFormData()).toEqual({ tags: ['test', 'case'] }); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ tags: ['foo', 'bar', 'dude'] }, true); + }); }); it('it shows error when tag is empty', async () => { appMockRender.render( - <MockHookWrapperComponent> + <FormTestComponent> <Tags isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); userEvent.type(screen.getByRole('combobox'), ' {enter}'); - await waitFor(() => { - expect(screen.getByText('A tag must contain at least one non-space character.')); - }); + expect(await screen.findByText('A tag must contain at least one non-space character.')); }); it('it shows error when tag is too long', async () => { const longTag = 'z'.repeat(MAX_LENGTH_PER_TAG + 1); appMockRender.render( - <MockHookWrapperComponent> + <FormTestComponent> <Tags isLoading={false} /> - </MockHookWrapperComponent> + </FormTestComponent> ); userEvent.paste(screen.getByRole('combobox'), `${longTag}`); userEvent.keyboard('{enter}'); - await waitFor(() => { - expect( - screen.getByText('The length of the tag is too long. The maximum length is 256 characters.') - ); - }); + expect( + await screen.findByText( + 'The length of the tag is too long. The maximum length is 256 characters.' + ) + ); }); }); 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 77480a81d6fca..ee013b882c487 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 @@ -121,7 +121,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); @@ -180,6 +180,52 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); }); }); }; 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 3ceebc5f04c14..17d341f834e84 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 @@ -115,7 +115,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); @@ -152,6 +152,52 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-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 584d4bc6507eb..478cb6d78f775 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 @@ -115,7 +115,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await deleteButton.click(); - await testSubjects.existOrFail('confirm-delete-custom-field-modal'); + await testSubjects.existOrFail('confirm-delete-modal'); await testSubjects.click('confirmModalConfirmButton'); @@ -152,6 +152,52 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(await testSubjects.getVisibleText('templates-list')).to.be('Template name\ntag-t1'); }); + + it('updates a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const editButton = await find.byCssSelector('[data-test-subj*="-template-edit"]'); + + await editButton.click(); + + await testSubjects.setValue('template-name-input', 'Updated template name!'); + await comboBox.setCustom('template-tags', 'tag-t1'); + await testSubjects.setValue('template-description-input', 'Template description updated'); + + const caseTitle = await find.byCssSelector( + `[data-test-subj="input"][aria-describedby="caseTitle"]` + ); + await caseTitle.focus(); + await caseTitle.type('!!'); + + await cases.create.setDescription('test description!!'); + + await cases.create.setTags('case-tag'); + await cases.create.setCategory('new!'); + + await testSubjects.click('common-flyout-save'); + expect(await testSubjects.exists('euiFlyoutCloseButton')).to.be(false); + + await retry.waitFor('templates-list', async () => { + return await testSubjects.exists('templates-list'); + }); + + expect(await testSubjects.getVisibleText('templates-list')).to.be( + 'Updated template name!\ntag-t1' + ); + }); + + it('deletes a template', async () => { + await testSubjects.existOrFail('templates-form-group'); + const deleteButton = await find.byCssSelector('[data-test-subj*="-template-delete"]'); + + await deleteButton.click(); + + await testSubjects.existOrFail('confirm-delete-modal'); + + await testSubjects.click('confirmModalConfirmButton'); + + await testSubjects.missingOrFail('template-list'); + }); }); }); }; From 8ebab818fa15b0276a638ce4bcb2ca3ec2658ccb Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:53:33 +0100 Subject: [PATCH 06/11] remove watch for tags and assignees --- .../case_form_fields/custom_fields.tsx | 3 + .../case_form_fields/index.test.tsx | 21 +-- .../components/case_form_fields/index.tsx | 13 +- .../components/create/assignees.test.tsx | 3 +- .../public/components/create/assignees.tsx | 138 ++++++++---------- .../public/components/create/tags.test.tsx | 35 +++-- .../cases/public/components/create/tags.tsx | 10 +- .../custom_fields/text/create.test.tsx | 16 ++ .../components/custom_fields/text/create.tsx | 3 +- .../public/components/custom_fields/types.ts | 1 + .../components/templates/form_fields.test.tsx | 27 ++-- .../components/templates/form_fields.tsx | 1 + .../public/components/templates/utils.ts | 7 +- 13 files changed, 141 insertions(+), 137 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 977f169171217..0655a9038a385 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 @@ -17,12 +17,14 @@ interface Props { isLoading: boolean; setCustomFieldsOptional: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; + isTemplateEditMode?: boolean; } const CustomFieldsComponent: React.FC<Props> = ({ isLoading, setCustomFieldsOptional, configurationCustomFields, + isTemplateEditMode, }) => { const sortedCustomFields = useMemo( () => sortCustomFieldsByLabel(configurationCustomFields), @@ -42,6 +44,7 @@ const CustomFieldsComponent: React.FC<Props> = ({ customFieldConfiguration={customField} key={customField.key} setAsOptional={setCustomFieldsOptional} + isTemplateEditMode={isTemplateEditMode} /> ); } 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 485882d82d38c..e095a8a915b76 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 @@ -24,6 +24,7 @@ jest.mock('../../containers/user_profiles/api'); describe('CaseFormFields', () => { let appMock: AppMockRenderer; const onSubmit = jest.fn(); + const formDefaultValue = { tags: [] }; const defaultProps = { isLoading: false, configurationCustomFields: [], @@ -36,7 +37,7 @@ describe('CaseFormFields', () => { it('renders correctly', async () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -46,7 +47,7 @@ describe('CaseFormFields', () => { it('renders case fields correctly', async () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -60,7 +61,7 @@ describe('CaseFormFields', () => { it('does not render customFields when empty', () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -70,7 +71,7 @@ describe('CaseFormFields', () => { it('renders customFields when not empty', async () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields isLoading={false} configurationCustomFields={customFieldsConfigurationMock} @@ -83,7 +84,7 @@ describe('CaseFormFields', () => { it('does not render assignees when no platinum license', () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -99,7 +100,7 @@ describe('CaseFormFields', () => { appMock = createAppMockRenderer({ license }); appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -109,7 +110,7 @@ describe('CaseFormFields', () => { it('calls onSubmit with case fields', async () => { appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -182,7 +183,7 @@ describe('CaseFormFields', () => { }; appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...newProps} /> </FormTestComponent> ); @@ -231,6 +232,7 @@ describe('CaseFormFields', () => { <FormTestComponent formDefaultValue={{ customFields: { [customFieldsConfigurationMock[0].key]: 'Test custom filed value' }, + tags: [], }} onSubmit={onSubmit} > @@ -266,7 +268,7 @@ describe('CaseFormFields', () => { appMock = createAppMockRenderer({ license }); appMock.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <CaseFormFields {...defaultProps} /> </FormTestComponent> ); @@ -304,6 +306,7 @@ describe('CaseFormFields', () => { <FormTestComponent formDefaultValue={{ assignees: [{ uid: userProfiles[1].uid }], + tags: [], }} onSubmit={onSubmit} > 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 c3d6e6113fd8d..573d4f5015de7 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,7 +7,6 @@ import React, { memo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; -import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Title } from '../create/title'; import { Tags } from '../create/tags'; import { Category } from '../create/category'; @@ -22,27 +21,24 @@ interface Props { isLoading: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; setCustomFieldsOptional?: boolean; + isEditMode?: boolean; } const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading, configurationCustomFields, setCustomFieldsOptional = false, + isEditMode, }) => { const { caseAssignmentAuthorized } = useCasesFeatures(); - const [{ assignees, tags }] = useFormData({ - watch: ['assignees', 'tags'], - }); return ( <EuiFlexGroup data-test-subj="case-form-fields" direction="column"> <Title isLoading={isLoading} /> - {caseAssignmentAuthorized ? ( - <Assignees currentAssignees={assignees} isLoading={isLoading} /> - ) : null} + {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} - <Tags isLoading={isLoading} currentTags={tags} /> + <Tags isLoading={isLoading} /> <Category isLoading={isLoading} /> @@ -54,6 +50,7 @@ const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading={isLoading} setCustomFieldsOptional={setCustomFieldsOptional} configurationCustomFields={configurationCustomFields} + isTemplateEditMode={isEditMode} /> </EuiFlexGroup> ); diff --git a/x-pack/plugins/cases/public/components/create/assignees.test.tsx b/x-pack/plugins/cases/public/components/create/assignees.test.tsx index 83b7802ce4a12..0ecc4f2c6a41b 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.test.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.test.tsx @@ -15,7 +15,6 @@ import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_l import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { userProfiles } from '../../containers/user_profiles/api.mock'; import { Assignees } from './assignees'; -import type { FormProps } from './schema'; import { act, waitFor, screen } from '@testing-library/react'; import * as api from '../../containers/user_profiles/api'; import type { UserProfile } from '@kbn/user-profile-components'; @@ -29,7 +28,7 @@ describe('Assignees', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>(); + const { form } = useForm(); globalForm = form; return <Form form={form}>{children}</Form>; diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index a9dfe624aeb08..7ac543e3a6fda 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -35,21 +35,19 @@ import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../../utils/permissions'; import { useIsUserTyping } from '../../common/use_is_user_typing'; -import type { Assignee } from '../user_profiles/types'; interface Props { isLoading: boolean; - currentAssignees?: Assignee[]; } +type UserProfileComboBoxOption = EuiComboBoxOptionOption<string> & UserProfileWithAvatar; + interface FieldProps { - field: FieldHook; - options: EuiComboBoxOptionOption[]; + field: FieldHook<CaseAssignees>; + options: UserProfileComboBoxOption[]; isLoading: boolean; isDisabled: boolean; currentUserProfile?: UserProfile; - selectedOptions: EuiComboBoxOptionOption[]; - setSelectedOptions: React.Dispatch<React.SetStateAction<EuiComboBoxOptionOption[]>>; onSearchComboChange: (value: string) => void; } @@ -75,28 +73,32 @@ const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({ data: userProfile.data, }); -const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value }); +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption<string>) => ({ + uid: option.value ?? '', +}); const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( - ({ - field, - isLoading, - isDisabled, - options, - currentUserProfile, - selectedOptions, - setSelectedOptions, - onSearchComboChange, - }) => { - const { setValue } = field; + ({ field, isLoading, isDisabled, options, currentUserProfile, onSearchComboChange }) => { + const { setValue, value: selectedAssignees } = field; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const selectedOptions: UserProfileComboBoxOption[] = selectedAssignees + .map(({ uid }) => { + const selectedUserProfile = options.find((userProfile) => userProfile.key === uid); + + if (selectedUserProfile) { + return selectedUserProfile; + } + + return null; + }) + .filter((value): value is UserProfileComboBoxOption => value != null); + const onComboChange = useCallback( - (currentOptions: EuiComboBoxOptionOption[]) => { - setSelectedOptions(currentOptions); + (currentOptions: Array<EuiComboBoxOptionOption<string>>) => { setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option))); }, - [setSelectedOptions, setValue] + [setValue] ); const onSelfAssign = useCallback(() => { @@ -104,62 +106,51 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( return; } - setSelectedOptions((prev) => [ - ...(prev ?? []), - userProfileToComboBoxOption(currentUserProfile), - ]); + setValue([...selectedAssignees, { uid: currentUserProfile.uid }]); + }, [currentUserProfile, selectedAssignees, setValue]); - setValue([ - ...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []), - { uid: currentUserProfile.uid }, - ]); - }, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]); + const renderOption = useCallback((option, searchValue: string, contentClassName: string) => { + const { user, data } = option as UserProfileComboBoxOption; - const renderOption = useCallback( - (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { - const { user, data } = option as EuiComboBoxOptionOption<string> & UserProfileWithAvatar; + const displayName = getUserDisplayName(user); - const displayName = getUserDisplayName(user); - - return ( + return ( + <EuiFlexGroup + alignItems="center" + justifyContent="flexStart" + gutterSize="s" + responsive={false} + > + <EuiFlexItem grow={false}> + <UserAvatar user={user} avatar={data.avatar} size="s" /> + </EuiFlexItem> <EuiFlexGroup alignItems="center" - justifyContent="flexStart" - gutterSize="s" + justifyContent="spaceBetween" + gutterSize="none" responsive={false} > - <EuiFlexItem grow={false}> - <UserAvatar user={user} avatar={data.avatar} size="s" /> + <EuiFlexItem> + <EuiHighlight search={searchValue} className={contentClassName}> + {displayName} + </EuiHighlight> </EuiFlexItem> - <EuiFlexGroup - alignItems="center" - justifyContent="spaceBetween" - gutterSize="none" - responsive={false} - > - <EuiFlexItem> - <EuiHighlight search={searchValue} className={contentClassName}> - {displayName} - </EuiHighlight> + {user.email && user.email !== displayName ? ( + <EuiFlexItem grow={false}> + <EuiTextColor color={'subdued'}> + <EuiHighlight search={searchValue} className={contentClassName}> + {user.email} + </EuiHighlight> + </EuiTextColor> </EuiFlexItem> - {user.email && user.email !== displayName ? ( - <EuiFlexItem grow={false}> - <EuiTextColor color={'subdued'}> - <EuiHighlight search={searchValue} className={contentClassName}> - {user.email} - </EuiHighlight> - </EuiTextColor> - </EuiFlexItem> - ) : null} - </EuiFlexGroup> + ) : null} </EuiFlexGroup> - ); - }, - [] - ); + </EuiFlexGroup> + ); + }, []); const isCurrentUserSelected = Boolean( - selectedOptions?.find((option) => option.value === currentUserProfile?.uid) + selectedAssignees?.find((assignee) => assignee.uid === currentUserProfile?.uid) ); return ( @@ -202,15 +193,10 @@ const AssigneesFieldComponent: React.FC<FieldProps> = React.memo( AssigneesFieldComponent.displayName = 'AssigneesFieldComponent'; -const AssigneesComponent: React.FC<Props> = ({ - isLoading: isLoadingForm, - currentAssignees = [], -}) => { +const AssigneesComponent: React.FC<Props> = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); - const [searchTerm, setSearchTerm] = useState(''); - const [selectedOptions, setSelectedOptions] = useState<EuiComboBoxOptionOption[]>(); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const hasOwners = owners.length > 0; @@ -232,14 +218,6 @@ const AssigneesComponent: React.FC<Props> = ({ userProfileToComboBoxOption(userProfile) ) ?? []; - const currentSelectedOptions = options.filter((option) => - currentAssignees.find((assignee) => assignee.uid === option.key) - ); - - if (currentSelectedOptions.length && !selectedOptions) { - setSelectedOptions(currentSelectedOptions); - } - const onSearchComboChange = (value: string) => { if (!isEmpty(value)) { setSearchTerm(value); @@ -265,8 +243,6 @@ const AssigneesComponent: React.FC<Props> = ({ componentProps={{ isLoading, isDisabled, - selectedOptions, - setSelectedOptions, options, onSearchComboChange, currentUserProfile, diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx index d6069371eb8d2..f55da4d6290bb 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -33,7 +33,7 @@ describe('Tags', () => { it('it renders', async () => { appMockRender.render( - <FormTestComponent> + <FormTestComponent formDefaultValue={{ tags: [] }}> <Tags isLoading={false} /> </FormTestComponent> ); @@ -43,8 +43,8 @@ describe('Tags', () => { it('it renders existing tags when provided', async () => { appMockRender.render( - <FormTestComponent> - <Tags isLoading={false} currentTags={['foo', 'bar']} /> + <FormTestComponent formDefaultValue={{ tags: ['foo', 'bar'] }}> + <Tags isLoading={false} /> </FormTestComponent> ); @@ -55,7 +55,7 @@ describe('Tags', () => { it('it changes the tags', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={{ tags: [] }} onSubmit={onSubmit}> <Tags isLoading={false} /> </FormTestComponent> ); @@ -72,8 +72,8 @@ describe('Tags', () => { it('it adds the tags to existing array', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <Tags isLoading={false} currentTags={['foo', 'bar']} /> + <FormTestComponent formDefaultValue={{ tags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <Tags isLoading={false} /> </FormTestComponent> ); @@ -89,12 +89,19 @@ describe('Tags', () => { it('it shows error when tag is empty', async () => { appMockRender.render( - <FormTestComponent> + <FormTestComponent formDefaultValue={{ tags: [] }} onSubmit={onSubmit}> <Tags isLoading={false} /> </FormTestComponent> ); - userEvent.type(screen.getByRole('combobox'), ' {enter}'); + userEvent.type(screen.getByRole('combobox'), ' '); + userEvent.keyboard('enter'); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ data: {} }, false); + }); expect(await screen.findByText('A tag must contain at least one non-space character.')); }); @@ -103,13 +110,19 @@ describe('Tags', () => { const longTag = 'z'.repeat(MAX_LENGTH_PER_TAG + 1); appMockRender.render( - <FormTestComponent> + <FormTestComponent formDefaultValue={{ tags: [longTag] }} onSubmit={onSubmit}> <Tags isLoading={false} /> </FormTestComponent> ); - userEvent.paste(screen.getByRole('combobox'), `${longTag}`); - userEvent.keyboard('{enter}'); + // userEvent.paste(screen.getByRole('combobox'), `${longTag}`); + // userEvent.keyboard('{enter}'); + + userEvent.click(await screen.findByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ data: { tags: [longTag] } }, false); + }); expect( await screen.findByText( diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index ffb92fdf84b50..79c1d0347dad9 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -11,13 +11,11 @@ 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'; -import { schema } from './schema'; interface Props { isLoading: boolean; - currentTags?: string[]; } -const TagsComponent: React.FC<Props> = ({ isLoading, currentTags }) => { +const TagsComponent: React.FC<Props> = ({ isLoading }) => { const { data: tagOptions = [], isLoading: isLoadingTags } = useGetTags(); const options = useMemo( () => @@ -27,16 +25,10 @@ const TagsComponent: React.FC<Props> = ({ isLoading, currentTags }) => { [tagOptions] ); - const tagsConfig = { - ...schema.tags, - defaultValue: currentTags ?? [], - }; - return ( <UseField path="tags" component={ComboBoxField} - config={tagsConfig} componentProps={{ idAria: 'caseTags', 'data-test-subj': 'caseTags', diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx index 9db8541993057..ec3eeae24728a 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -53,6 +53,22 @@ describe('Create ', () => { ); }); + it('does not render default value when in isTemplateEditMode', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + isTemplateEditMode={true} + /> + </FormTestComponent> + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveValue(''); + }); + it('renders loading state correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> 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 4fae9d7b4816d..48bdc2527f96d 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 @@ -17,12 +17,13 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, + isTemplateEditMode, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ required: setAsOptional ? false : required, label, - ...(defaultValue && { defaultValue: String(defaultValue) }), + ...(defaultValue && !isTemplateEditMode && { defaultValue: String(defaultValue) }), }); return ( 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 b735d4ca316b0..e7ccfa4d38a2a 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -31,6 +31,7 @@ export interface CustomFieldType<T extends CaseUICustomField> { customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; setAsOptional?: boolean; + isTemplateEditMode?: boolean; }>; } 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 3c513f85bd41d..f9cbc82d49ee5 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 @@ -25,6 +25,7 @@ const useGetChoicesMock = useGetChoices as jest.Mock; describe('form fields', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); + const formDefaultValue = { tags: [] }; const defaultProps = { connectors: connectorsMock, currentConfiguration: { @@ -52,7 +53,7 @@ describe('form fields', () => { it('renders correctly', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -62,7 +63,7 @@ describe('form fields', () => { it('renders all steps', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -75,7 +76,7 @@ describe('form fields', () => { it('renders template fields correctly', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -88,7 +89,7 @@ describe('form fields', () => { it('renders case fields', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -133,7 +134,7 @@ describe('form fields', () => { it('renders sync alerts correctly', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -151,7 +152,7 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...newProps} /> </FormTestComponent> ); @@ -161,7 +162,7 @@ describe('form fields', () => { it('renders default connector correctly', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -184,7 +185,7 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...newProps} /> </FormTestComponent> ); @@ -200,7 +201,7 @@ describe('form fields', () => { }); appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -210,7 +211,7 @@ describe('form fields', () => { it('calls onSubmit with template fields', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -247,7 +248,7 @@ describe('form fields', () => { it('calls onSubmit with case fields', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...defaultProps} /> </FormTestComponent> ); @@ -296,7 +297,7 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...newProps} /> </FormTestComponent> ); @@ -353,7 +354,7 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <FormFields {...newProps} /> </FormTestComponent> ); 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 1b4a2e1784f74..a71eac1502b30 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -58,6 +58,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ configurationCustomFields={configurationCustomFields} isLoading={isSubmitting} setCustomFieldsOptional={true} + isEditMode={isEditMode} /> ), }), diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index dba25d16c96ed..426b286a17970 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -42,8 +42,8 @@ export const convertTemplateCustomFields = ( export const templateDeserializer = (data: TemplateConfiguration): TemplateFormProps => { if (data !== null) { - const { key, name, description, tags, caseFields } = data; - const { connector, customFields, settings, ...rest } = caseFields ?? {}; + const { key, name, description, tags: templateTags, caseFields } = data; + const { connector, customFields, settings, tags, ...rest } = caseFields ?? {}; const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); const convertedCustomFields = convertTemplateCustomFields(customFields); @@ -51,10 +51,11 @@ export const templateDeserializer = (data: TemplateConfiguration): TemplateFormP key, name, templateDescription: description, - templateTags: tags, + templateTags, connectorId: connector?.id ?? 'none', fields: connectorFields.fields, customFields: convertedCustomFields ?? {}, + tags: tags ?? [], ...rest, }; } From 868858291cbce3bbf09d4825db607ae759bf6d1e Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 13 Jun 2024 16:02:06 +0100 Subject: [PATCH 07/11] fix tags tests changes, add defaultValue tests for custom fields --- .../case_form_fields/custom_fields.tsx | 2 +- .../public/components/create/tags.test.tsx | 109 +++++++----------- .../custom_fields/text/create.test.tsx | 4 +- .../components/custom_fields/text/create.tsx | 4 +- .../custom_fields/toggle/create.test.tsx | 14 +++ .../custom_fields/toggle/create.tsx | 3 +- .../public/components/custom_fields/types.ts | 2 +- .../components/templates/form_fields.tsx | 2 +- 8 files changed, 67 insertions(+), 73 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 0655a9038a385..d1d45771edfb2 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 @@ -44,7 +44,7 @@ const CustomFieldsComponent: React.FC<Props> = ({ customFieldConfiguration={customField} key={customField.key} setAsOptional={setCustomFieldsOptional} - isTemplateEditMode={isTemplateEditMode} + isEditMode={isTemplateEditMode} /> ); } diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx index f55da4d6290bb..ed78d78928f0e 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -5,16 +5,20 @@ * 2.0. */ +import type { FC, PropsWithChildren } from 'react'; import React from 'react'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +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 { Tags } from './tags'; +import type { FormProps } from './schema'; +import { schema } from './schema'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { MAX_LENGTH_PER_TAG } from '../../../common/constants'; -import { FormTestComponent } from '../../common/test_utils'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/use_get_tags'); @@ -22,8 +26,25 @@ jest.mock('../../containers/use_get_tags'); const useGetTagsMock = useGetTags as jest.Mock; describe('Tags', () => { + let globalForm: FormHook; let appMockRender: AppMockRenderer; - const onSubmit = jest.fn(); + + const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { + const { form } = useForm<FormProps>({ + defaultValue: { tags: [] }, + schema: { + tags: schema.tags, + }, + }); + + globalForm = form; + + return ( + <TestProviders> + <Form form={form}>{children}</Form> + </TestProviders> + ); + }; beforeEach(() => { jest.clearAllMocks(); @@ -33,101 +54,59 @@ describe('Tags', () => { it('it renders', async () => { appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: [] }}> - <Tags isLoading={false} /> - </FormTestComponent> - ); - - expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); - }); - - it('it renders existing tags when provided', async () => { - appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: ['foo', 'bar'] }}> + <MockHookWrapperComponent> <Tags isLoading={false} /> - </FormTestComponent> + </MockHookWrapperComponent> ); - expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); - expect(await screen.findByText('foo')).toBeInTheDocument(); - expect(await screen.findByText('bar')).toBeInTheDocument(); - }); - - it('it changes the tags', async () => { - appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: [] }} onSubmit={onSubmit}> - <Tags isLoading={false} /> - </FormTestComponent> - ); - - userEvent.type(await screen.findByRole('combobox'), 'test{enter}'); - userEvent.type(await screen.findByRole('combobox'), 'case{enter}'); - - userEvent.click(await screen.findByText('Submit')); - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith({ tags: ['test', 'case'] }, true); + expect(screen.getByTestId('caseTags')).toBeInTheDocument(); }); }); - it('it adds the tags to existing array', async () => { + it('it changes the tags', async () => { appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <MockHookWrapperComponent> <Tags isLoading={false} /> - </FormTestComponent> + </MockHookWrapperComponent> ); - userEvent.paste(await screen.findByRole('combobox'), 'dude'); - userEvent.keyboard('{enter}'); - - userEvent.click(await screen.findByText('Submit')); + userEvent.type(screen.getByRole('combobox'), 'test{enter}'); + userEvent.type(screen.getByRole('combobox'), 'case{enter}'); - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith({ tags: ['foo', 'bar', 'dude'] }, true); - }); + expect(globalForm.getFormData()).toEqual({ tags: ['test', 'case'] }); }); it('it shows error when tag is empty', async () => { appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: [] }} onSubmit={onSubmit}> + <MockHookWrapperComponent> <Tags isLoading={false} /> - </FormTestComponent> + </MockHookWrapperComponent> ); - userEvent.type(screen.getByRole('combobox'), ' '); - userEvent.keyboard('enter'); - - userEvent.click(await screen.findByText('Submit')); + userEvent.type(screen.getByRole('combobox'), ' {enter}'); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith({ data: {} }, false); + expect(screen.getByText('A tag must contain at least one non-space character.')); }); - - expect(await screen.findByText('A tag must contain at least one non-space character.')); }); it('it shows error when tag is too long', async () => { const longTag = 'z'.repeat(MAX_LENGTH_PER_TAG + 1); appMockRender.render( - <FormTestComponent formDefaultValue={{ tags: [longTag] }} onSubmit={onSubmit}> + <MockHookWrapperComponent> <Tags isLoading={false} /> - </FormTestComponent> + </MockHookWrapperComponent> ); - // userEvent.paste(screen.getByRole('combobox'), `${longTag}`); - // userEvent.keyboard('{enter}'); - - userEvent.click(await screen.findByText('Submit')); + userEvent.paste(screen.getByRole('combobox'), `${longTag}`); + userEvent.keyboard('{enter}'); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith({ data: { tags: [longTag] } }, false); + expect( + screen.getByText('The length of the tag is too long. The maximum length is 256 characters.') + ); }); - - expect( - await screen.findByText( - 'The length of the tag is too long. The maximum length is 256 characters.' - ) - ); }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx index ec3eeae24728a..773fdaa2ad34b 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -53,13 +53,13 @@ describe('Create ', () => { ); }); - it('does not render default value when in isTemplateEditMode', async () => { + it('does not render default value when in isEditMode', async () => { render( <FormTestComponent onSubmit={onSubmit}> <Create isLoading={false} customFieldConfiguration={customFieldConfiguration} - isTemplateEditMode={true} + isEditMode={true} /> </FormTestComponent> ); 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 48bdc2527f96d..bb4e15edaa72d 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 @@ -17,13 +17,13 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, - isTemplateEditMode, + isEditMode, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ required: setAsOptional ? false : required, label, - ...(defaultValue && !isTemplateEditMode && { defaultValue: String(defaultValue) }), + ...(defaultValue && !isEditMode && { defaultValue: String(defaultValue) }), }); return ( diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx index 9672b3c8bb6be..4275a237dd86c 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -36,6 +36,20 @@ describe('Create ', () => { expect(await screen.findByRole('switch')).toBeChecked(); // defaultValue true }); + it('does not render default value when in isEditMode', async () => { + render( + <FormTestComponent onSubmit={onSubmit}> + <Create + isLoading={false} + customFieldConfiguration={customFieldConfiguration} + isEditMode={true} + /> + </FormTestComponent> + ); + + expect(await screen.findByRole('switch')).not.toBeChecked(); + }); + it('updates the value correctly', async () => { render( <FormTestComponent onSubmit={onSubmit}> 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 2d3f51bc4f678..614e208ab0d9d 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,6 +14,7 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, + isEditMode, }) => { const { key, label, defaultValue } = customFieldConfiguration; @@ -21,7 +22,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ <UseField path={`customFields.${key}`} component={ToggleField} - config={{ defaultValue: defaultValue ? defaultValue : false }} + config={{ defaultValue: defaultValue && !isEditMode ? defaultValue : false }} key={key} label={label} componentProps={{ 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 e7ccfa4d38a2a..aaee719352a1b 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -31,7 +31,7 @@ export interface CustomFieldType<T extends CaseUICustomField> { customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; setAsOptional?: boolean; - isTemplateEditMode?: boolean; + isEditMode?: boolean; }>; } 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 a71eac1502b30..1f47407d241bb 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -62,7 +62,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ /> ), }), - [isSubmitting, configurationCustomFields] + [isSubmitting, configurationCustomFields, isEditMode] ); const thirdStep = useMemo( From 71831ef347c096f8dc92f00da70d4859f8ebb385 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 13 Jun 2024 17:15:01 +0100 Subject: [PATCH 08/11] fix incident types issue --- .../connectors/resilient/case_fields.tsx | 15 +++++++++------ .../cases/public/components/create/tags.tsx | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) 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 e8260a69a3301..ee7538543ec41 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 @@ -77,12 +77,15 @@ const ResilientFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = field.setValue(changedOptions.map((option) => option.value as string)); }; - const selectedOptions = (field.value ?? []).map((incidentType) => ({ - value: incidentType, - label: - (allIncidentTypes ?? []).find((type) => incidentType === type.id.toString())?.name ?? - '', - })); + const selectedOptions = + field.value && allIncidentTypes?.length + ? field.value.map((incidentType) => ({ + value: incidentType, + label: + allIncidentTypes.find((type) => incidentType === type.id.toString())?.name ?? + '', + })) + : []; return ( <EuiFormRow diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index 79c1d0347dad9..77c4cf7a7ba00 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -33,8 +33,8 @@ const TagsComponent: React.FC<Props> = ({ isLoading }) => { idAria: 'caseTags', 'data-test-subj': 'caseTags', euiFieldProps: { - placeholder: '', fullWidth: true, + placeholder: '', disabled: isLoading || isLoadingTags, options, noSuggestions: false, From 744262e43f452346244e8d72e9a4433d7258a52a Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:41:33 +0100 Subject: [PATCH 09/11] cleanup --- .../case_form_fields/custom_fields.test.tsx | 18 +++++ .../case_form_fields/custom_fields.tsx | 6 +- .../components/case_form_fields/index.tsx | 2 +- .../custom_fields/text/create.test.tsx | 4 +- .../components/custom_fields/text/create.tsx | 4 +- .../custom_fields/toggle/create.test.tsx | 4 +- .../custom_fields/toggle/create.tsx | 4 +- .../public/components/custom_fields/types.ts | 2 +- .../public/components/templates/connector.tsx | 7 +- .../public/components/templates/form.test.tsx | 40 +++++++++++ .../public/components/templates/form.tsx | 1 - .../components/templates/form_fields.test.tsx | 3 +- .../public/components/templates/index.tsx | 10 ++- .../templates/template_fields.test.tsx | 5 +- .../components/templates/template_fields.tsx | 70 ++++++++----------- .../templates/template_tags.test.tsx | 25 +++---- .../components/templates/template_tags.tsx | 10 +-- .../public/components/templates/utils.ts | 2 +- 18 files changed, 132 insertions(+), 85 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 2d11e8c73236a..95f7ef1aaa09b 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 @@ -24,6 +24,7 @@ describe('CustomFields', () => { configurationCustomFields: customFieldsConfigurationMock, isLoading: false, setCustomFieldsOptional: false, + isEditMode: false, }; beforeEach(() => { @@ -77,6 +78,23 @@ describe('CustomFields', () => { expect(screen.getAllByTestId('form-optional-field-label')).toHaveLength(2); }); + it('should not set default value when in edit mode', async () => { + appMockRender.render( + <FormTestComponent onSubmit={onSubmit}> + <CustomFields + isLoading={false} + configurationCustomFields={[customFieldsConfigurationMock[0]]} + setCustomFieldsOptional={false} + isEditMode={true} + /> + </FormTestComponent> + ); + + expect( + screen.queryByText(`${customFieldsConfigurationMock[0].defaultValue}`) + ).not.toBeInTheDocument(); + }); + it('should sort the custom fields correctly', async () => { const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse(); 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 d1d45771edfb2..ad44eac1728a5 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 @@ -17,14 +17,14 @@ interface Props { isLoading: boolean; setCustomFieldsOptional: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; - isTemplateEditMode?: boolean; + isEditMode?: boolean; } const CustomFieldsComponent: React.FC<Props> = ({ isLoading, setCustomFieldsOptional, configurationCustomFields, - isTemplateEditMode, + isEditMode, }) => { const sortedCustomFields = useMemo( () => sortCustomFieldsByLabel(configurationCustomFields), @@ -44,7 +44,7 @@ const CustomFieldsComponent: React.FC<Props> = ({ customFieldConfiguration={customField} key={customField.key} setAsOptional={setCustomFieldsOptional} - isEditMode={isTemplateEditMode} + setDefaultValue={!isEditMode} /> ); } 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 573d4f5015de7..e80a64a9825b4 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 @@ -50,7 +50,7 @@ const CaseFormFieldsComponent: React.FC<Props> = ({ isLoading={isLoading} setCustomFieldsOptional={setCustomFieldsOptional} configurationCustomFields={configurationCustomFields} - isTemplateEditMode={isEditMode} + isEditMode={isEditMode} /> </EuiFlexGroup> ); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx index 773fdaa2ad34b..0b62466fa6858 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -53,13 +53,13 @@ describe('Create ', () => { ); }); - it('does not render default value when in isEditMode', async () => { + it('does not render default value when setDefaultValue is false', async () => { render( <FormTestComponent onSubmit={onSubmit}> <Create isLoading={false} customFieldConfiguration={customFieldConfiguration} - isEditMode={true} + setDefaultValue={false} /> </FormTestComponent> ); 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 bb4e15edaa72d..3a2c54286cd62 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 @@ -17,13 +17,13 @@ const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, - isEditMode, + setDefaultValue = true, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ required: setAsOptional ? false : required, label, - ...(defaultValue && !isEditMode && { defaultValue: String(defaultValue) }), + ...(defaultValue && setDefaultValue && { defaultValue: String(defaultValue) }), }); return ( diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx index 4275a237dd86c..8eb7c50300840 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -36,13 +36,13 @@ describe('Create ', () => { expect(await screen.findByRole('switch')).toBeChecked(); // defaultValue true }); - it('does not render default value when in isEditMode', async () => { + it('does not render default value when setDefaultValue is false', async () => { render( <FormTestComponent onSubmit={onSubmit}> <Create isLoading={false} customFieldConfiguration={customFieldConfiguration} - isEditMode={true} + setDefaultValue={false} /> </FormTestComponent> ); 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 614e208ab0d9d..eb3ad2b114e57 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,7 +14,7 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ customFieldConfiguration, isLoading, - isEditMode, + setDefaultValue = true, }) => { const { key, label, defaultValue } = customFieldConfiguration; @@ -22,7 +22,7 @@ const CreateComponent: CustomFieldType<CaseCustomFieldToggle>['Create'] = ({ <UseField path={`customFields.${key}`} component={ToggleField} - config={{ defaultValue: defaultValue && !isEditMode ? defaultValue : false }} + config={{ defaultValue: defaultValue && setDefaultValue ? defaultValue : false }} key={key} label={label} componentProps={{ 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 aaee719352a1b..a1dcffaec6b97 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/types.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -31,7 +31,7 @@ export interface CustomFieldType<T extends CaseUICustomField> { customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; setAsOptional?: boolean; - isEditMode?: boolean; + setDefaultValue?: boolean; }>; } diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 2e793f0a4bd5d..4194ee6ed5860 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'; @@ -42,10 +42,7 @@ const ConnectorComponent: React.FC<Props> = ({ connectors, }); - const connector = useMemo( - () => getConnectorById(connectorId, connectors) ?? null, - [connectorId, connectors] - ); + const connector = getConnectorById(connectorId, connectors) ?? null; if (!hasReadPermissions) { 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 3f4e52e53396a..4754675b6abe4 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -190,6 +190,46 @@ describe('TemplateForm', () => { }); }); + it('serializes the template field data correctly with existing fields', async () => { + let formState: FormState<TemplateFormProps>; + + const onChangeState = (state: FormState<TemplateFormProps>) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: { ...templatesConfigurationMock[0], tags: ['foo', 'bar'] }, + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(<TemplateForm {...newProps} />); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + await act(async () => { + const { data, isValid } = await formState!.submit(); + + expect(isValid).toBe(true); + + expect(data).toEqual({ + key: expect.anything(), + name: 'First test template', + title: '', + description: '', + templateDescription: 'This is a first test template', + tags: [], + connectorId: 'none', + severity: '', + syncAlerts: true, + category: null, + templateTags: ['foo', 'bar'], + }); + }); + }); + it('serializes the case field data correctly', async () => { let formState: FormState<TemplateFormProps>; diff --git a/x-pack/plugins/cases/public/components/templates/form.tsx b/x-pack/plugins/cases/public/components/templates/form.tsx index e35bc9ad2164d..2e5149aa76efe 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -43,7 +43,6 @@ const FormComponent: React.FC<Props> = ({ }, options: { stripEmptyFields: false }, schema, - // serializer: templateSerializer, deserializer: templateDeserializer, }); 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 f9cbc82d49ee5..61c709dd7a027 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 @@ -25,7 +25,7 @@ const useGetChoicesMock = useGetChoices as jest.Mock; describe('form fields', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); - const formDefaultValue = { tags: [] }; + const formDefaultValue = { tags: [], templateTags: [] }; const defaultProps = { connectors: connectorsMock, currentConfiguration: { @@ -111,6 +111,7 @@ describe('form fields', () => { tags: ['case-1', 'case-2'], category: 'new', severity: CaseSeverity.MEDIUM, + templateTags: [], }} onSubmit={onSubmit} > diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index 40717189d8581..9671b9aee8556 100644 --- a/x-pack/plugins/cases/public/components/templates/index.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.tsx @@ -61,6 +61,14 @@ const TemplatesComponent: React.FC<Props> = ({ [setError, onEditTemplate] ); + const handleDeleteTemplate = useCallback( + (key: string) => { + setError(false); + onDeleteTemplate(key); + }, + [setError, onDeleteTemplate] + ); + return ( <EuiDescribedFormGroup fullWidth @@ -81,7 +89,7 @@ const TemplatesComponent: React.FC<Props> = ({ <TemplatesList templates={templates} onEditTemplate={handleEditTemplate} - onDeleteTemplate={onDeleteTemplate} + onDeleteTemplate={handleDeleteTemplate} /> {error ? ( <EuiFlexGroup justifyContent="center"> 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 index 9638e0b93f5d4..8073c2e25fb41 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.test.tsx @@ -16,6 +16,7 @@ import { TemplateFields } from './template_fields'; describe('Template fields', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; const defaultProps = { isLoading: false, configurationTemplateTags: [], @@ -28,7 +29,7 @@ describe('Template fields', () => { it('renders template fields correctly', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <TemplateFields {...defaultProps} /> </FormTestComponent> ); @@ -70,7 +71,7 @@ describe('Template fields', () => { it('calls onSubmit with template fields', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> <TemplateFields {...defaultProps} /> </FormTestComponent> ); diff --git a/x-pack/plugins/cases/public/components/templates/template_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx index 78faf9718c3c7..adc817cee41c1 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +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'; @@ -15,45 +15,35 @@ import { TemplateTags } from './template_tags'; const TemplateFieldsComponent: React.FC<{ isLoading: boolean; configurationTemplateTags: string[]; -}> = ({ isLoading = false, configurationTemplateTags }) => { - const [{ templateTags }] = useFormData({ - watch: ['templateTags'], - }); - - return ( - <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} - tagOptions={configurationTemplateTags} - currentTags={templateTags} - /> - <UseField - path="templateDescription" - component={TextAreaField} - componentProps={{ - labelAppend: OptionalFieldLabel, - euiFieldProps: { - 'data-test-subj': 'template-description-input', - fullWidth: true, - isLoading, - }, - }} - /> - </EuiFlexGroup> - ); -}; +}> = ({ 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} tagOptions={configurationTemplateTags} /> + <UseField + path="templateDescription" + component={TextAreaField} + componentProps={{ + labelAppend: OptionalFieldLabel, + euiFieldProps: { + 'data-test-subj': 'template-description-input', + fullWidth: true, + isLoading, + }, + }} + /> + </EuiFlexGroup> +); TemplateFieldsComponent.displayName = 'TemplateFields'; 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 index 816a5462b4ffa..6a99321bb7727 100644 --- a/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_tags.test.tsx @@ -18,6 +18,7 @@ import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl'; describe('TemplateTags', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); + const formDefaultValue = { templateTags: [] }; beforeEach(() => { jest.clearAllMocks(); @@ -26,8 +27,8 @@ describe('TemplateTags', () => { it('renders template tags', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tagOptions={[]} currentTags={[]} /> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> </FormTestComponent> ); @@ -36,8 +37,8 @@ describe('TemplateTags', () => { it('renders loading state', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={true} tagOptions={[]} currentTags={[]} /> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={true} tagOptions={[]} /> </FormTestComponent> ); @@ -47,8 +48,8 @@ describe('TemplateTags', () => { it('shows template tags options', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tagOptions={['foo', 'bar', 'test']} currentTags={[]} /> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={['foo', 'bar', 'test']} /> </FormTestComponent> ); @@ -61,8 +62,8 @@ describe('TemplateTags', () => { it('shows template tags with current values', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tagOptions={[]} currentTags={['foo', 'bar']} /> + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> </FormTestComponent> ); @@ -75,8 +76,8 @@ describe('TemplateTags', () => { it('adds template tag ', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tagOptions={[]} currentTags={[]} /> + <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> </FormTestComponent> ); @@ -102,8 +103,8 @@ describe('TemplateTags', () => { it('adds new template tag to existing tags', async () => { appMockRenderer.render( - <FormTestComponent onSubmit={onSubmit}> - <TemplateTags isLoading={false} tagOptions={[]} currentTags={['foo', 'bar']} /> + <FormTestComponent formDefaultValue={{ templateTags: ['foo', 'bar'] }} onSubmit={onSubmit}> + <TemplateTags isLoading={false} tagOptions={[]} /> </FormTestComponent> ); diff --git a/x-pack/plugins/cases/public/components/templates/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx index 8f1143e4b4b0e..92f141a73eb85 100644 --- a/x-pack/plugins/cases/public/components/templates/template_tags.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_tags.tsx @@ -10,28 +10,20 @@ 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'; -import { schema } from './schema'; interface Props { isLoading: boolean; - currentTags: string[]; tagOptions: string[]; } -const TemplateTagsComponent: React.FC<Props> = ({ isLoading, currentTags, tagOptions }) => { +const TemplateTagsComponent: React.FC<Props> = ({ isLoading, tagOptions }) => { const options = tagOptions.map((label) => ({ label, })); - const templateTagsConfig = { - ...schema.templateTags, - defaultValue: currentTags ?? [], - }; - return ( <UseField path="templateTags" component={ComboBoxField} - config={templateTagsConfig} componentProps={{ idAria: 'template-tags', 'data-test-subj': 'template-tags', diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 426b286a17970..cc2d806ee21cc 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -51,7 +51,7 @@ export const templateDeserializer = (data: TemplateConfiguration): TemplateFormP key, name, templateDescription: description, - templateTags, + templateTags: templateTags ?? [], connectorId: connector?.id ?? 'none', fields: connectorFields.fields, customFields: convertedCustomFields ?? {}, From 64e165f386e34b1b542edb1c7e348cd5419999cf Mon Sep 17 00:00:00 2001 From: Janki Salvi <jankigaurav.salvi@elastic.co> Date: Wed, 19 Jun 2024 18:32:37 +0100 Subject: [PATCH 10/11] PR feedback --- .../connectors/jira/case_fields.test.tsx | 20 ++ .../connectors/jira/search_issues.tsx | 13 +- .../connectors/jira/use_get_issue.test.tsx | 1 - .../components/templates/templates_list.tsx | 22 +- .../components/templates/translations.ts | 6 +- .../public/components/templates/utils.test.ts | 191 +++++++++++++++++- .../public/components/templates/utils.ts | 2 +- 7 files changed, 235 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx index d4311e5a6fcf8..5d172539ea29a 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.test.tsx @@ -259,6 +259,26 @@ describe('Jira Fields', () => { expect(await screen.findByText('Person Task')).toBeInTheDocument(); }); + it('resets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + <MockFormWrapperComponent fields={newFields}> + <Fields connector={connector} /> + </MockFormWrapperComponent> + ); + + const checkbox = within(await screen.findByTestId('search-parent-issues')).getByTestId( + 'comboBoxSearchInput' + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('comboBoxClearButton')); + + expect(checkbox).toHaveValue(''); + }); + it('should submit Jira connector', async () => { appMockRenderer.render( <MockFormWrapperComponent fields={fields}> 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 2aead9d58afc8..3193777389c7f 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, memo } from 'react'; +import React, { useState, memo, useRef } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFormRow } from '@elastic/eui'; @@ -30,6 +30,7 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, currentParent [] ); const { http } = useKibana().services; + const isFirstRender = useRef(true); const { isFetching: isLoadingIssues, data: issuesData } = useGetIssues({ http, @@ -49,7 +50,12 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, currentParent const issue = issueData?.data ?? null; - if (!isLoadingIssue && issue && !selectedOptions.find((option) => option.value === issue.key)) { + if ( + isFirstRender.current && + !isLoadingIssue && + issue && + !selectedOptions.find((option) => option.value === issue.key) + ) { setSelectedOptions([{ label: issue.title, value: issue.key }]); } @@ -64,7 +70,8 @@ const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, currentParent const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { setSelectedOptions(changedOptions); - field.setValue(changedOptions[0].value ?? ''); + field.setValue(changedOptions.length ? changedOptions[0].value : ''); + isFirstRender.current = false; }; return ( diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx index 7c1fd4e80dffc..876738025e6a8 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx @@ -56,7 +56,6 @@ describe('useGetIssue', () => { () => useGetIssue({ http, - actionConnector, id: 'RJ-107', }), { wrapper: appMockRender.AppWrapper } 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 96ea67932bd8b..15d78aeb3107c 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -30,21 +30,21 @@ export interface Props { const TemplatesListComponent: React.FC<Props> = (props) => { const { templates, onEditTemplate, onDeleteTemplate } = props; const { euiTheme } = useEuiTheme(); - const [selectedItem, setSelectedItem] = useState<TemplateConfiguration | null>(null); + const [itemToBeDeleted, SetItemToBeDeleted] = useState<TemplateConfiguration | null>(null); const onConfirm = useCallback(() => { - if (selectedItem) { - onDeleteTemplate(selectedItem.key); + if (itemToBeDeleted) { + onDeleteTemplate(itemToBeDeleted.key); } - setSelectedItem(null); - }, [onDeleteTemplate, setSelectedItem, selectedItem]); + SetItemToBeDeleted(null); + }, [onDeleteTemplate, SetItemToBeDeleted, itemToBeDeleted]); const onCancel = useCallback(() => { - setSelectedItem(null); + SetItemToBeDeleted(null); }, []); - const showModal = Boolean(selectedItem); + const showModal = Boolean(itemToBeDeleted); return templates.length ? ( <> @@ -101,7 +101,7 @@ const TemplatesListComponent: React.FC<Props> = (props) => { aria-label={`${template.key}-template-delete`} iconType="minusInCircle" color="danger" - onClick={() => setSelectedItem(template)} + onClick={() => SetItemToBeDeleted(template)} /> </EuiFlexItem> </EuiFlexGroup> @@ -112,10 +112,10 @@ const TemplatesListComponent: React.FC<Props> = (props) => { </React.Fragment> ))} </EuiFlexItem> - {showModal && selectedItem ? ( + {showModal && itemToBeDeleted ? ( <DeleteConfirmationModal - title={i18n.DELETE_TITLE(selectedItem.name)} - message={i18n.DELETE_MESSAGE(selectedItem.name)} + title={i18n.DELETE_TITLE(itemToBeDeleted.name)} + message={i18n.DELETE_MESSAGE(itemToBeDeleted.name)} onCancel={onCancel} onConfirm={onConfirm} /> diff --git a/x-pack/plugins/cases/public/components/templates/translations.ts b/x-pack/plugins/cases/public/components/templates/translations.ts index c800008b3d995..2993070046813 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -66,13 +66,13 @@ export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorF }); export const DELETE_TITLE = (name: string) => - i18n.translate('xpack.cases.templates.deleteTitle', { + i18n.translate('xpack.cases.configuration.deleteTitle', { values: { name }, - defaultMessage: 'Delete {name} ?', + defaultMessage: 'Delete {name}?', }); export const DELETE_MESSAGE = (name: string) => - i18n.translate('xpack.cases.templates.deleteMessage', { + i18n.translate('xpack.cases.configuration.deleteMessage', { values: { name }, defaultMessage: 'This action will permanently delete {name}.', }); 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 fdbed01c54029..1c6c5139dc01d 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { getTemplateSerializedData, removeEmptyFields } from './utils'; +import { CaseSeverity, CaseUI } from '../../../common'; +import { convertTemplateCustomFields, getTemplateSerializedData, removeEmptyFields, templateDeserializer } from './utils'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; describe('utils', () => { describe('getTemplateSerializedData', () => { @@ -138,4 +142,189 @@ describe('utils', () => { }); }); }); + + describe('templateDeserializer', () => { + it('deserialzies initial data correctly', () => { + const res = templateDeserializer({ key: 'temlate_1', name: 'Template 1', caseFields: null }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies template data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + description: 'This is first template', + tags: ['t1', 't2'], + caseFields: null, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: 'This is first template', + templateTags: ['t1', 't2'], + tags: [], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies case fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + title: 'Case title', + description: 'This is test case', + category: null, + tags: ['foo', 'bar'], + severity: CaseSeverity.LOW, + assignees: [{ uid: userProfiles[0].uid }], + connectorId: 'none', + customFields: {}, + fields: null, + }); + }); + + it('deserialzies custom fields data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + customFields: [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'none', + customFields: { + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }, + fields: null, + }); + }); + + it('deserialzies connector data correctly', () => { + const res = templateDeserializer({ + key: 'temlate_1', + name: 'Template 1', + caseFields: { + connector: { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: { + category: 'software', + urgency: '1', + severity: null, + impact: null, + subcategory: null, + }, + }, + }, + }); + + expect(res).toEqual({ + key: 'temlate_1', + name: 'Template 1', + templateDescription: '', + templateTags: [], + tags: [], + connectorId: 'servicenow-1', + customFields: {}, + fields: { + category: 'software', + impact: undefined, + severity: undefined, + subcategory: undefined, + urgency: '1', + }, + }); + }); + }); + + describe('convertTemplateCustomFields', () => { + it('converts data correctly', () => { + const data = [ + { + key: customFieldsConfigurationMock[0].key, + type: CustomFieldTypes.TEXT, + value: 'this is first custom field value', + }, + { + key: customFieldsConfigurationMock[1].key, + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ] as CaseUI['customFields']; + + const res = convertTemplateCustomFields(data); + + expect(res).toEqual({ + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, + }); + }); + + it('returns null when customFields empty', () => { + const res = convertTemplateCustomFields([]); + + expect(res).toEqual(null); + }); + + it('returns null when customFields undefined', () => { + const res = convertTemplateCustomFields(undefined); + + expect(res).toEqual(null); + }); + + it('returns null when customFields empty', () => { + const res = convertTemplateCustomFields([]); + + expect(res).toEqual(null); + }); + + }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index cc2d806ee21cc..d118942e93071 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -50,7 +50,7 @@ export const templateDeserializer = (data: TemplateConfiguration): TemplateFormP return { key, name, - templateDescription: description, + templateDescription: description ?? '', templateTags: templateTags ?? [], connectorId: connector?.id ?? 'none', fields: connectorFields.fields, From fc0db28b79de8ee5f648b53497bfade2c28cd001 Mon Sep 17 00:00:00 2001 From: Janki Salvi <jankigaurav.salvi@elastic.co> Date: Wed, 19 Jun 2024 18:51:05 +0100 Subject: [PATCH 11/11] lint fix --- .../public/components/templates/utils.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) 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 1c6c5139dc01d..e23e6f56b257f 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -5,8 +5,14 @@ * 2.0. */ -import { CaseSeverity, CaseUI } from '../../../common'; -import { convertTemplateCustomFields, getTemplateSerializedData, removeEmptyFields, templateDeserializer } from './utils'; +import type { CaseUI } from '../../../common'; +import { CaseSeverity } from '../../../common'; +import { + convertTemplateCustomFields, + getTemplateSerializedData, + removeEmptyFields, + templateDeserializer, +} from './utils'; import { userProfiles } from '../../containers/user_profiles/api.mock'; import { customFieldsConfigurationMock } from '../../containers/mock'; import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; @@ -303,8 +309,8 @@ describe('utils', () => { const res = convertTemplateCustomFields(data); expect(res).toEqual({ - [customFieldsConfigurationMock[0].key]: 'this is first custom field value', - [customFieldsConfigurationMock[1].key]: true, + [customFieldsConfigurationMock[0].key]: 'this is first custom field value', + [customFieldsConfigurationMock[1].key]: true, }); }); @@ -319,12 +325,5 @@ describe('utils', () => { expect(res).toEqual(null); }); - - it('returns null when customFields empty', () => { - const res = convertTemplateCustomFields([]); - - expect(res).toEqual(null); - }); - }); });