From c3b0759630023707856b2192792997b29dc36656 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Thu, 20 Jun 2024 00:58:48 +0100 Subject: [PATCH] [ResponseOps][Cases] Allow users to edit or delete template (#185877) ## Summary Merging into feature branch. Implements edit and delete functionality https://github.com/elastic/kibana/issues/181864 Edit or delete template from case settings page. https://github.com/elastic/kibana/assets/117571355/f6fd45fa-c1eb-45ab-8936-02c764dbadc4 **How to test** - Go to Cases > Settings - Add a template - Click on edit template icon - Update the fields - Click save - Click on delete template icon - Click on confirm modal - Click save **Scenarios:** - Edit template with different custom fields - Edit template with connector - Delete template **Flaky Test** [here](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6331) ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../case_form_fields/custom_fields.test.tsx | 18 ++ .../case_form_fields/custom_fields.tsx | 3 + .../case_form_fields/index.test.tsx | 120 ++++++- .../components/case_form_fields/index.tsx | 3 + .../delete_confirmation_modal.test.tsx | 53 +++ .../delete_confirmation_modal.tsx | 42 +++ .../configure_cases/flyout.test.tsx | 193 ++++++++++- .../components/configure_cases/index.test.tsx | 106 +++++- .../components/configure_cases/index.tsx | 45 ++- .../public/components/connectors/constants.ts | 2 + .../connectors/jira/case_fields.test.tsx | 42 +++ .../connectors/jira/case_fields.tsx | 4 +- .../connectors/jira/search_issues.tsx | 27 +- .../connectors/jira/use_get_issue.test.tsx | 131 ++++++++ .../connectors/jira/use_get_issue.tsx | 56 ++++ .../connectors/resilient/case_fields.tsx | 15 +- .../components/create/assignees.test.tsx | 3 +- .../public/components/create/assignees.tsx | 122 ++++--- .../cases/public/components/create/tags.tsx | 1 - .../custom_fields_list/index.test.tsx | 10 +- .../custom_fields_list/index.tsx | 5 +- .../custom_fields/text/create.test.tsx | 16 + .../components/custom_fields/text/create.tsx | 3 +- .../custom_fields/toggle/create.test.tsx | 14 + .../custom_fields/toggle/create.tsx | 3 +- .../public/components/custom_fields/types.ts | 1 + .../components/templates/connector.test.tsx | 49 ++- .../public/components/templates/connector.tsx | 8 +- .../public/components/templates/form.test.tsx | 302 ++++++++++++++++-- .../public/components/templates/form.tsx | 14 +- .../components/templates/form_fields.test.tsx | 60 +++- .../components/templates/form_fields.tsx | 20 +- .../components/templates/index.test.tsx | 38 ++- .../public/components/templates/index.tsx | 33 +- .../public/components/templates/schema.tsx | 1 - .../templates/template_fields.test.tsx | 74 ++++- .../components/templates/template_fields.tsx | 2 +- .../templates/template_tags.test.tsx | 56 +++- .../components/templates/template_tags.tsx | 9 +- .../templates/templates_list.test.tsx | 74 ++++- .../components/templates/templates_list.tsx | 59 +++- .../components/templates/translations.ts | 12 + .../public/components/templates/types.ts | 2 +- .../public/components/templates/utils.test.ts | 202 +++++++++++- .../public/components/templates/utils.ts | 45 ++- .../apps/cases/group2/configure.ts | 48 ++- .../observability/cases/configure.ts | 48 ++- .../security/ftr/cases/configure.ts | 48 ++- 48 files changed, 2026 insertions(+), 216 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 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/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( + + + + ); + + 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 977f169171217..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,12 +17,14 @@ interface Props { isLoading: boolean; setCustomFieldsOptional: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; + isEditMode?: boolean; } const CustomFieldsComponent: React.FC = ({ isLoading, setCustomFieldsOptional, configurationCustomFields, + isEditMode, }) => { const sortedCustomFields = useMemo( () => sortCustomFieldsByLabel(configurationCustomFields), @@ -42,6 +44,7 @@ const CustomFieldsComponent: React.FC = ({ customFieldConfiguration={customField} key={customField.key} setAsOptional={setCustomFieldsOptional} + setDefaultValue={!isEditMode} /> ); } 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..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( - + ); @@ -46,7 +47,7 @@ describe('CaseFormFields', () => { it('renders case fields correctly', async () => { appMock.render( - + ); @@ -60,7 +61,7 @@ describe('CaseFormFields', () => { it('does not render customFields when empty', () => { appMock.render( - + ); @@ -70,7 +71,7 @@ describe('CaseFormFields', () => { it('renders customFields when not empty', async () => { appMock.render( - + { it('does not render assignees when no platinum license', () => { appMock.render( - + ); @@ -99,7 +100,7 @@ describe('CaseFormFields', () => { appMock = createAppMockRenderer({ license }); appMock.render( - + ); @@ -109,7 +110,7 @@ describe('CaseFormFields', () => { it('calls onSubmit with case fields', async () => { appMock.render( - + ); @@ -145,6 +146,36 @@ describe('CaseFormFields', () => { }); }); + it('calls onSubmit with existing case fields', async () => { + appMock.render( + + + + ); + + 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, @@ -152,7 +183,7 @@ describe('CaseFormFields', () => { }; appMock.render( - + ); @@ -191,6 +222,44 @@ describe('CaseFormFields', () => { }); }); + it('calls onSubmit with existing custom fields', async () => { + const newProps = { + ...defaultProps, + configurationCustomFields: customFieldsConfigurationMock, + }; + + appMock.render( + + + + ); + + 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' }, @@ -199,7 +268,7 @@ describe('CaseFormFields', () => { appMock = createAppMockRenderer({ license }); appMock.render( - + ); @@ -225,4 +294,37 @@ describe('CaseFormFields', () => { ); }); }); + + it('calls onSubmit with existing assignees', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMock = createAppMockRenderer({ license }); + + appMock.render( + + + + ); + + 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/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index d47929e676182..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 @@ -21,12 +21,14 @@ interface Props { isLoading: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; setCustomFieldsOptional?: boolean; + isEditMode?: boolean; } const CaseFormFieldsComponent: React.FC = ({ isLoading, configurationCustomFields, setCustomFieldsOptional = false, + isEditMode, }) => { const { caseAssignmentAuthorized } = useCasesFeatures(); @@ -48,6 +50,7 @@ const CaseFormFieldsComponent: React.FC = ({ isLoading={isLoading} setCustomFieldsOptional={setCustomFieldsOptional} configurationCustomFields={configurationCustomFields} + isEditMode={isEditMode} /> ); 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(); + + 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(); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(); + + 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 = ({ + title, + message, + onCancel, + onConfirm, +}) => { + return ( + + {message} + + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); 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..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 @@ -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) => ( + + ); + + appMockRender = createAppMockRenderer({ license }); + + appMockRender.render( + + ); + + // 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(); @@ -406,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, }); }); }); @@ -419,7 +507,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={[]} currentConfiguration={currentConfiguration} @@ -455,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, }); }); }); @@ -472,7 +562,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={[]} currentConfiguration={newConfig} @@ -501,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, }); }); }); @@ -530,7 +626,7 @@ describe('CommonFlyout ', () => { initialValue={{ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', + description: 'test description', }} connectors={connectorsMock} currentConfiguration={newConfig} @@ -558,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, @@ -571,6 +673,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) => ( + + ); + + appMockRender.render( + + ); + + 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', + category: 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(); 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..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'; @@ -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')); @@ -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(); + + 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(); + + 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/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index 51346cbddc09c..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; @@ -356,6 +357,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); @@ -363,6 +400,7 @@ export const ConfigureCases: React.FC = React.memo(() => { const onTemplateSave = useCallback( (data: TemplateFormProps) => { + const serializedData = getTemplateSerializedData(data); const { connectorId, fields, @@ -373,7 +411,7 @@ export const ConfigureCases: React.FC = React.memo(() => { templateTags, templateDescription, ...otherCaseFields - } = data; + } = serializedData; const transformedCustomFields = templateCustomFields ? transformCustomFieldsData(templateCustomFields, customFields) @@ -457,9 +495,10 @@ export const ConfigureCases: React.FC = React.memo(() => { renderHeader={() => {i18n.CREATE_TEMPLATE}} renderBody={({ onChange }) => ( )} @@ -550,6 +589,8 @@ export const ConfigureCases: React.FC = React.memo(() => { isLoading={isLoadingCaseConfiguration} disabled={isLoadingCaseConfiguration} onAddTemplate={() => setFlyOutVisibility({ type: 'template', visible: true })} + onEditTemplate={onEditTemplate} + onDeleteTemplate={onDeleteTemplate} /> 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..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 @@ -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,38 @@ describe('Jira Fields', () => { expect(await screen.findByTestId('prioritySelect')).toHaveValue('Low'); }); + it('sets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + + + + ); + + expect(await screen.findByText('Person Task')).toBeInTheDocument(); + }); + + it('resets existing parent correctly', async () => { + const newFields = { ...fields, parent: 'personKey' }; + + appMockRenderer.render( + + + + ); + + 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( 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 = ({ 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 = ({ co
- + 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..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'; @@ -17,17 +17,20 @@ 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 = ({ actionConnector }) => { +const SearchIssuesComponent: React.FC = ({ actionConnector, currentParent }) => { const [query, setQuery] = useState(null); const [selectedOptions, setSelectedOptions] = useState>>( [] ); const { http } = useKibana().services; + const isFirstRender = useRef(true); const { isFetching: isLoadingIssues, data: issuesData } = useGetIssues({ http, @@ -35,10 +38,27 @@ const SearchIssuesComponent: React.FC = ({ 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 ( + isFirstRender.current && + !isLoadingIssue && + issue && + !selectedOptions.find((option) => option.value === issue.key) + ) { + setSelectedOptions([{ label: issue.title, value: issue.key }]); + } + return ( {(field) => { @@ -50,7 +70,8 @@ const SearchIssuesComponent: React.FC = ({ actionConnector }) => { const onChangeComboBox = (changedOptions: Array>) => { 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 new file mode 100644 index 0000000000000..876738025e6a8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issue.test.tsx @@ -0,0 +1,131 @@ +/* + * 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; + +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, + 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, 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; 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 = 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 ( { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC> = ({ children }) => { - const { form } = useForm(); + const { form } = useForm(); globalForm = form; return
{children}
; diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx index 1e8464dc1a2ed..7ac543e3a6fda 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -40,14 +40,14 @@ interface Props { isLoading: boolean; } +type UserProfileComboBoxOption = EuiComboBoxOptionOption & UserProfileWithAvatar; + interface FieldProps { - field: FieldHook; - options: EuiComboBoxOptionOption[]; + field: FieldHook; + options: UserProfileComboBoxOption[]; isLoading: boolean; isDisabled: boolean; currentUserProfile?: UserProfile; - selectedOptions: EuiComboBoxOptionOption[]; - setSelectedOptions: React.Dispatch>; onSearchComboChange: (value: string) => void; } @@ -73,28 +73,32 @@ const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({ data: userProfile.data, }); -const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value }); +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ + uid: option.value ?? '', +}); const AssigneesFieldComponent: React.FC = 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>) => { setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option))); }, - [setSelectedOptions, setValue] + [setValue] ); const onSelfAssign = useCallback(() => { @@ -102,62 +106,51 @@ const AssigneesFieldComponent: React.FC = React.memo( return; } - setSelectedOptions((prev) => [ - ...(prev ?? []), - userProfileToComboBoxOption(currentUserProfile), - ]); - - setValue([ - ...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []), - { uid: currentUserProfile.uid }, - ]); - }, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]); + setValue([...selectedAssignees, { uid: currentUserProfile.uid }]); + }, [currentUserProfile, selectedAssignees, setValue]); - const renderOption = useCallback( - (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { - const { user, data } = option as EuiComboBoxOptionOption & UserProfileWithAvatar; + const renderOption = useCallback((option, searchValue: string, contentClassName: string) => { + const { user, data } = option as UserProfileComboBoxOption; - const displayName = getUserDisplayName(user); + const displayName = getUserDisplayName(user); - return ( + return ( + + + + - - + + + {displayName} + - - - - {displayName} - + {user.email && user.email !== displayName ? ( + + + + {user.email} + + - {user.email && user.email !== displayName ? ( - - - - {user.email} - - - - ) : null} - + ) : null} - ); - }, - [] - ); + + ); + }, []); const isCurrentUserSelected = Boolean( - selectedOptions?.find((option) => option.value === currentUserProfile?.uid) + selectedAssignees?.find((assignee) => assignee.uid === currentUserProfile?.uid) ); return ( @@ -204,7 +197,6 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); - const [selectedOptions, setSelectedOptions] = useState(); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const hasOwners = owners.length > 0; @@ -251,8 +243,6 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { componentProps={{ isLoading, isDisabled, - selectedOptions, - setSelectedOptions, options, onSearchComboChange, currentUserProfile, diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index e08f9ace8dde0..77c4cf7a7ba00 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -29,7 +29,6 @@ const TagsComponent: React.FC = ({ isLoading }) => { { ) ); - 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) => { {showModal && selectedItem ? ( 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..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,6 +53,22 @@ describe('Create ', () => { ); }); + it('does not render default value when setDefaultValue is false', async () => { + render( + + + + ); + + expect( + await screen.findByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveValue(''); + }); + it('renders loading state correctly', async () => { render( diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx index 4fae9d7b4816d..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,12 +17,13 @@ const CreateComponent: CustomFieldType['Create'] = ({ customFieldConfiguration, isLoading, setAsOptional, + setDefaultValue = true, }) => { const { key, label, required, defaultValue } = customFieldConfiguration; const config = getTextFieldConfig({ required: setAsOptional ? false : required, label, - ...(defaultValue && { 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 9672b3c8bb6be..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,6 +36,20 @@ describe('Create ', () => { expect(await screen.findByRole('switch')).toBeChecked(); // defaultValue true }); + it('does not render default value when setDefaultValue is false', async () => { + render( + + + + ); + + expect(await screen.findByRole('switch')).not.toBeChecked(); + }); + it('updates the value correctly', async () => { render( diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx index 2d3f51bc4f678..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,6 +14,7 @@ import type { CustomFieldType } from '../types'; const CreateComponent: CustomFieldType['Create'] = ({ customFieldConfiguration, isLoading, + setDefaultValue = true, }) => { const { key, label, defaultValue } = customFieldConfiguration; @@ -21,7 +22,7 @@ const CreateComponent: CustomFieldType['Create'] = ({ { customFieldConfiguration: CasesConfigurationUICustomField; isLoading: boolean; setAsOptional?: boolean; + setDefaultValue?: boolean; }>; } 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( + + + + ); + + 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( + + + + ); + + 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( diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/templates/connector.tsx index 2886da2663333..4194ee6ed5860 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/templates/connector.tsx @@ -23,16 +23,16 @@ interface Props { connectors: ActionConnector[]; isLoading: boolean; configurationConnectorId: string; + isEditMode?: boolean; } const ConnectorComponent: React.FC = ({ 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 = ({ connectors, }); + const connector = getConnectorById(connectorId, connectors) ?? null; + if (!hasReadPermissions) { return ( @@ -57,7 +59,7 @@ const ConnectorComponent: React.FC = ({ 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.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx index 9b30f89f24713..4754675b6abe4 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(); @@ -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(); @@ -173,31 +179,76 @@ 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, }); }); }); - it('serializes the case field data correctly', async () => { + it('serializes the template field data correctly with existing fields', async () => { let formState: FormState; const onChangeState = (state: FormState) => (formState = state); - appMockRenderer.render(); + const newProps = { + ...defaultProps, + initialValue: { ...templatesConfigurationMock[0], tags: ['foo', 'bar'] }, + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(); await waitFor(() => { expect(formState).not.toBeUndefined(); }); - userEvent.paste(await screen.findByTestId('template-name-input'), 'Template 1'); + await act(async () => { + const { data, isValid } = await formState!.submit(); - userEvent.paste( - await screen.findByTestId('template-description-input'), - 'this is a first template' + 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; + + const onChangeState = (state: FormState) => (formState = state); + + appMockRenderer.render( + ); + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + const caseTitle = await screen.findByTestId('caseTitle'); userEvent.paste(within(caseTitle).getByTestId('input'), 'Case with Template 1'); @@ -222,14 +273,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; + + const onChangeState = (state: FormState) => (formState = state); + + const newProps = { + ...defaultProps, + initialValue: templatesConfigurationMock[3], + connectors: [], + onChange: onChangeState, + isEditMode: true, + }; + + appMockRenderer.render(); + + 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 +335,7 @@ 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 +372,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; + + const onChangeState = (state: FormState) => (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(); + + 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 +472,10 @@ 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 +520,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; + + const onChangeState = (state: FormState) => (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(); + + 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 e7ca2451bb179..2e5149aa76efe 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -9,18 +9,19 @@ 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 } 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) => void; initialValue: TemplateFormProps | null; connectors: ActionConnector[]; currentConfiguration: CasesConfigurationUI; + isEditMode?: boolean; } const FormComponent: React.FC = ({ @@ -28,6 +29,7 @@ const FormComponent: React.FC = ({ initialValue, connectors, currentConfiguration, + isEditMode = false, }) => { const keyDefaultValue = useMemo(() => uuidv4(), []); @@ -35,12 +37,13 @@ const FormComponent: React.FC = ({ defaultValue: initialValue ?? { key: keyDefaultValue, name: '', - templateDescription: '', - templateTags: [], + description: '', + tags: [], + caseFields: null, }, options: { stripEmptyFields: false }, schema, - serializer: templateSerializer, + deserializer: templateDeserializer, }); const { submit, isValid, isSubmitting } = form; @@ -57,6 +60,7 @@ const FormComponent: React.FC = ({ isSubmitting={isSubmitting} connectors={connectors} currentConfiguration={currentConfiguration} + isEditMode={isEditMode} /> ); 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..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 @@ -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'; @@ -25,6 +25,7 @@ const useGetChoicesMock = useGetChoices as jest.Mock; describe('form fields', () => { let appMockRenderer: AppMockRenderer; const onSubmit = jest.fn(); + const formDefaultValue = { tags: [], templateTags: [] }; const defaultProps = { connectors: connectorsMock, currentConfiguration: { @@ -52,7 +53,7 @@ describe('form fields', () => { it('renders correctly', async () => { appMockRenderer.render( - + ); @@ -62,7 +63,7 @@ describe('form fields', () => { it('renders all steps', async () => { appMockRenderer.render( - + ); @@ -75,7 +76,7 @@ describe('form fields', () => { it('renders template fields correctly', async () => { appMockRenderer.render( - + ); @@ -88,7 +89,7 @@ describe('form fields', () => { it('renders case fields', async () => { appMockRenderer.render( - + ); @@ -101,9 +102,40 @@ describe('form fields', () => { expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); }); + it('renders case fields with existing value', async () => { + appMockRenderer.render( + + + + ); + + 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( - + ); @@ -121,7 +153,7 @@ describe('form fields', () => { }; appMockRenderer.render( - + ); @@ -131,7 +163,7 @@ describe('form fields', () => { it('renders default connector correctly', async () => { appMockRenderer.render( - + ); @@ -154,7 +186,7 @@ describe('form fields', () => { }; appMockRenderer.render( - + ); @@ -170,7 +202,7 @@ describe('form fields', () => { }); appMockRenderer.render( - + ); @@ -180,7 +212,7 @@ describe('form fields', () => { it('calls onSubmit with template fields', async () => { appMockRenderer.render( - + ); @@ -217,7 +249,7 @@ describe('form fields', () => { it('calls onSubmit with case fields', async () => { appMockRenderer.render( - + ); @@ -266,7 +298,7 @@ describe('form fields', () => { }; appMockRenderer.render( - + ); @@ -323,7 +355,7 @@ describe('form fields', () => { }; appMockRenderer.render( - + ); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 6a063f5710a17..1f47407d241bb 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 = ({ isSubmitting = false, connectors, currentConfiguration, + isEditMode, }) => { const { isSyncAlertsEnabled } = useCasesFeatures(); const { customFields: configurationCustomFields, connector, templates } = currentConfiguration; @@ -56,10 +58,11 @@ const FormFieldsComponent: React.FC = ({ configurationCustomFields={configurationCustomFields} isLoading={isSubmitting} setCustomFieldsOptional={true} + isEditMode={isEditMode} /> ), }), - [isSubmitting, configurationCustomFields] + [isSubmitting, configurationCustomFields, isEditMode] ); const thirdStep = useMemo( @@ -74,16 +77,15 @@ const FormFieldsComponent: React.FC = ({ () => ({ title: i18n.CONNECTOR_FIELDS, children: ( -
- -
+ ), }), - [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(); + + 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(); + + 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(); diff --git a/x-pack/plugins/cases/public/components/templates/index.tsx b/x-pack/plugins/cases/public/components/templates/index.tsx index f86037ce97813..9671b9aee8556 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; + onEditTemplate: (key: string) => void; + onDeleteTemplate: (key: string) => void; } -const TemplatesComponent: React.FC = ({ disabled, isLoading, templates, onAddTemplate }) => { +const TemplatesComponent: React.FC = ({ + disabled, + isLoading, + templates, + onAddTemplate, + onEditTemplate, + onDeleteTemplate, +}) => { const { permissions } = useCasesContext(); const canAddTemplates = permissions.create && permissions.update; const [error, setError] = useState(false); @@ -44,6 +53,22 @@ const TemplatesComponent: React.FC = ({ disabled, isLoading, templates, o setError(false); }, [onAddTemplate, error, templates]); + const handleEditTemplate = useCallback( + (key: string) => { + setError(false); + onEditTemplate(key); + }, + [setError, onEditTemplate] + ); + + const handleDeleteTemplate = useCallback( + (key: string) => { + setError(false); + onDeleteTemplate(key); + }, + [setError, onDeleteTemplate] + ); + return ( = ({ disabled, isLoading, templates, o {templates.length ? ( <> - + {error ? ( 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 = { connectorId: { labelAppend: OptionalFieldLabel, label: i18n.CONNECTORS, - defaultValue: 'none', }, fields: { defaultValue: null, 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..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( - + ); @@ -38,9 +39,39 @@ describe('Template fields', () => { expect(await screen.findByTestId('template-description-input')).toBeInTheDocument(); }); + it('renders template fields with existing value', async () => { + appMockRenderer.render( + + + + ); + + 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( - + ); @@ -49,7 +80,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 +101,41 @@ describe('Template fields', () => { ); }); }); + + it('calls onSubmit with updated template fields', async () => { + appMockRenderer.render( + + + + ); + + 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_fields.tsx b/x-pack/plugins/cases/public/components/templates/template_fields.tsx index e9c34af53ca69..adc817cee41c1 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -29,7 +29,7 @@ const TemplateFieldsComponent: React.FC<{ }, }} /> - + { 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( - - + + ); @@ -36,8 +37,8 @@ describe('TemplateTags', () => { it('renders loading state', async () => { appMockRenderer.render( - - + + ); @@ -47,8 +48,8 @@ describe('TemplateTags', () => { it('shows template tags options', async () => { appMockRenderer.render( - - + + ); @@ -59,10 +60,24 @@ describe('TemplateTags', () => { expect(await screen.findByText('foo')).toBeInTheDocument(); }); + it('shows template tags with current values', async () => { + appMockRenderer.render( + + + + ); + + 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( - - + + ); @@ -85,4 +100,29 @@ describe('TemplateTags', () => { ); }); }); + + it('adds new template tag to existing tags', async () => { + appMockRenderer.render( + + + + ); + + 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/template_tags.tsx b/x-pack/plugins/cases/public/components/templates/template_tags.tsx index 16c37154a650f..92f141a73eb85 100644 --- a/x-pack/plugins/cases/public/components/templates/template_tags.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_tags.tsx @@ -12,11 +12,11 @@ import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components' import * as i18n from './translations'; interface Props { isLoading: boolean; - tags: string[]; + tagOptions: string[]; } -const TemplateTagsComponent: React.FC = ({ isLoading, tags }) => { - const options = tags.map((label) => ({ +const TemplateTagsComponent: React.FC = ({ isLoading, tagOptions }) => { + const options = tagOptions.map((label) => ({ label, })); @@ -24,13 +24,12 @@ const TemplateTagsComponent: React.FC = ({ isLoading, tags }) => { { 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( + + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-edit`) + ).toBeInTheDocument(); + }); + + it('renders delete button', async () => { + appMockRender.render( + + ); + + expect( + await screen.findByTestId(`${templatesConfigurationMock[0].key}-template-delete`) + ).toBeInTheDocument(); + }); + + it('renders delete modal', async () => { + appMockRender.render( + + ); + + 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(); + + 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(); + + 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); + }); + }); }); 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..15d78aeb3107c 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) => { - const { templates } = props; + const { templates, onEditTemplate, onDeleteTemplate } = props; const { euiTheme } = useEuiTheme(); + const [itemToBeDeleted, SetItemToBeDeleted] = useState(null); + + const onConfirm = useCallback(() => { + if (itemToBeDeleted) { + onDeleteTemplate(itemToBeDeleted.key); + } + + SetItemToBeDeleted(null); + }, [onDeleteTemplate, SetItemToBeDeleted, itemToBeDeleted]); + + const onCancel = useCallback(() => { + SetItemToBeDeleted(null); + }, []); + + const showModal = Boolean(itemToBeDeleted); return templates.length ? ( <> @@ -65,12 +84,42 @@ const TemplatesListComponent: React.FC = (props) => { : null} + + + + onEditTemplate(template.key)} + /> + + + SetItemToBeDeleted(template)} + /> + + + ))} + {showModal && itemToBeDeleted ? ( + + ) : null} ) : 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..2993070046813 100644 --- a/x-pack/plugins/cases/public/components/templates/translations.ts +++ b/x-pack/plugins/cases/public/components/templates/translations.ts @@ -65,6 +65,18 @@ export const CONNECTOR_FIELDS = i18n.translate('xpack.cases.templates.connectorF defaultMessage: 'External Connector Fields', }); +export const DELETE_TITLE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteTitle', { + values: { name }, + defaultMessage: 'Delete {name}?', + }); + +export const DELETE_MESSAGE = (name: string) => + i18n.translate('xpack.cases.configuration.deleteMessage', { + values: { name }, + 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 & CaseFieldsProps & { templateTags?: string[]; - templateDescription: string; + templateDescription?: string; }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts index 35ea896753a0f..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,12 +5,22 @@ * 2.0. */ -import { templateSerializer, removeEmptyFields } 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'; describe('utils', () => { - describe('templateSerializer', () => { + describe('getTemplateSerializedData', () => { it('serializes empty fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: '', name: '', templateDescription: '', @@ -26,7 +36,7 @@ describe('utils', () => { }); it('serializes connectors fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: '', name: '', templateDescription: '', @@ -39,7 +49,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 +68,7 @@ describe('utils', () => { }); it('serializes custom fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: 'key_1', name: 'template 1', templateDescription: '', @@ -81,7 +91,7 @@ describe('utils', () => { }); it('serializes connector fields correctly', () => { - const res = templateSerializer({ + const res = getTemplateSerializedData({ key: 'key_1', name: 'template 1', templateDescription: '', @@ -138,4 +148,182 @@ 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); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index 65262b7cefaa4..d118942e93071 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>(obj: T): Partial { @@ -22,7 +24,46 @@ export function removeEmptyFields>(obj: T): Pa ) as T; } -export const templateSerializer = (data: TemplateFormProps): TemplateFormProps => { +export const convertTemplateCustomFields = ( + customFields?: CaseUI['customFields'] +): Record | 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) { + 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); + + return { + key, + name, + templateDescription: description ?? '', + templateTags: templateTags ?? [], + connectorId: connector?.id ?? 'none', + fields: connectorFields.fields, + customFields: convertedCustomFields ?? {}, + tags: tags ?? [], + ...rest, + }; + } + + return data; +}; + +export const getTemplateSerializedData = (data: TemplateFormProps): TemplateFormProps => { if (data !== null) { const { fields = null, ...rest } = data; const connectorFields = getConnectorsFormSerializer({ fields }); 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 511981d067874..35fee3f110a2a 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'); + }); }); }); };