diff --git a/x-pack/plugins/cases/common/constants/owners.ts b/x-pack/plugins/cases/common/constants/owners.ts index 8ac7164ef75cc..a7628628a7dcc 100644 --- a/x-pack/plugins/cases/common/constants/owners.ts +++ b/x-pack/plugins/cases/common/constants/owners.ts @@ -56,8 +56,8 @@ export const OWNER_INFO: Record = { [GENERAL_CASES_OWNER]: { id: GENERAL_CASES_OWNER, appId: 'management', - label: 'Stack', - iconType: 'casesApp', + label: 'Management', + iconType: 'managementApp', appRoute: '/app/management/insightsAndAlerting', validRuleConsumers: [AlertConsumers.ML, AlertConsumers.STACK_ALERTS, AlertConsumers.EXAMPLE], }, diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 8676fa2ddc347..6d75b30dd119d 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -130,7 +130,6 @@ export type CasesConfigurationUI = Pick< >; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; - export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number]; export type SortOrder = 'asc' | 'desc'; diff --git a/x-pack/plugins/cases/public/components/create/assignees.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx similarity index 57% rename from x-pack/plugins/cases/public/components/create/assignees.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx index 0ecc4f2c6a41b..f0b73cb8bf990 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.test.tsx @@ -40,113 +40,99 @@ describe('Assignees', () => { }); it('renders', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('does not render the assign yourself link when the current user profile is undefined', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(undefined as unknown as UserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - expect(result.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(screen.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); it('selects the current user correctly', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); - }); + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); it('disables the assign yourself button if the current user is already selected', async () => { const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(result.getByTestId('create-case-assign-yourself-link')); - }); + userEvent.click(await screen.findByTestId('create-case-assign-yourself-link')); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); }); - expect(result.getByTestId('create-case-assign-yourself-link')).toBeDisabled(); + expect(await screen.findByTestId('create-case-assign-yourself-link')).toBeDisabled(); }); it('assignees users correctly', async () => { - const result = appMockRender.render( + appMockRender.render( ); await waitFor(() => { - expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - await act(async () => { - await userEvent.type(result.getByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - }); + await userEvent.type(await screen.findByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); - await waitFor(() => { - expect( - result.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') - ).toBeInTheDocument(); - }); + expect( + await screen.findByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') + ).toBeInTheDocument(); - await waitFor(async () => { - expect(result.getByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - }); + expect(await screen.findByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); - act(() => { - userEvent.click(result.getByText(`${currentUserProfile.user.full_name}`)); - }); + userEvent.click(await screen.findByText(`${currentUserProfile.user.full_name}`)); await waitFor(() => { expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); @@ -185,25 +171,62 @@ describe('Assignees', () => { ); await waitFor(() => { - expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + expect(screen.queryByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); + userEvent.click(await screen.findByTestId('comboBoxSearchInput')); + + expect(await screen.findByText('Turtle')).toBeInTheDocument(); + expect(await screen.findByText('turtle')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); + + // ensure that the similar user is still available for selection + expect(await screen.findByText('turtle')).toBeInTheDocument(); + }); + + it('fetches the unknown user profiles using bulk_get', async () => { + // the profile is not returned by the suggest API + const userProfile = { + uid: 'u_qau3P4T1H-_f1dNHyEOWJzVkGQhLH1gnNMVvYxqmZcs_0', + enabled: true, + data: {}, + user: { + username: 'uncertain_crawdad', + email: 'uncertain_crawdad@profiles.elastic.co', + full_name: 'Uncertain Crawdad', + }, + }; + + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + spyOnBulkGetUserProfiles.mockResolvedValue([userProfile]); + + appMockRender.render( + + + + ); + + expect(screen.queryByText(userProfile.user.full_name)).not.toBeInTheDocument(); + act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); + globalForm.setFieldValue('assignees', [{ uid: userProfile.uid }]); }); await waitFor(() => { - expect(screen.getByText('Turtle')).toBeInTheDocument(); - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(globalForm.getFormData()).toEqual({ + assignees: [{ uid: userProfile.uid }], + }); }); - act(() => { - userEvent.click(screen.getByText('Turtle'), undefined, { skipPointerEventsCheck: true }); - }); - - // ensure that the similar user is still available for selection await waitFor(() => { - expect(screen.getByText('turtle')).toBeInTheDocument(); + expect(spyOnBulkGetUserProfiles).toBeCalledTimes(1); + expect(spyOnBulkGetUserProfiles).toHaveBeenCalledWith({ + security: expect.anything(), + uids: [userProfile.uid], + }); }); + + expect(await screen.findByText(userProfile.user.full_name)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx similarity index 86% rename from x-pack/plugins/cases/public/components/create/assignees.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx index 7ac543e3a6fda..6e56e7d154a2a 100644 --- a/x-pack/plugins/cases/public/components/create/assignees.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/assignees.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash'; +import { isEmpty, differenceWith } from 'lodash'; import React, { memo, useCallback, useState } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { @@ -23,18 +23,22 @@ import type { FieldConfig, FieldHook } from '@kbn/es-ui-shared-plugin/static/for import { UseField, getFieldValidityAndErrorMessage, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { CaseAssignees } from '../../../common/types/domain'; import { MAX_ASSIGNEES_PER_CASE } from '../../../common/constants'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; -import { OptionalFieldLabel } from './optional_field_label'; -import * as i18n from './translations'; +import { OptionalFieldLabel } from '../optional_field_label'; +import * as i18n from '../create/translations'; import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../../utils/permissions'; import { useIsUserTyping } from '../../common/use_is_user_typing'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; + +const FIELD_ID = 'assignees'; interface Props { isLoading: boolean; @@ -172,6 +176,7 @@ const AssigneesFieldComponent: React.FC = React.memo( } isInvalid={isInvalid} error={errorMessage} + data-test-subj="caseAssignees" > = ({ isLoading: isLoadingForm }) => { const { owner: owners } = useCasesContext(); + const [{ assignees }] = useFormData<{ assignees?: CaseAssignees }>({ watch: [FIELD_ID] }); const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); @@ -204,7 +210,7 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { useGetCurrentUserProfile(); const { - data: userProfiles, + data: userProfiles = [], isLoading: isLoadingSuggest, isFetching: isFetchingSuggest, } = useSuggestUserProfiles({ @@ -213,10 +219,22 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { onDebounce, }); + const assigneesWithoutProfiles = differenceWith( + assignees ?? [], + userProfiles ?? [], + (assignee, userProfile) => assignee.uid === userProfile.uid + ); + + const { data: bulkUserProfiles = new Map(), isFetching: isLoadingBulkGetUserProfiles } = + useBulkGetUserProfiles({ uids: assigneesWithoutProfiles.map((assignee) => assignee.uid) }); + + const bulkUserProfilesAsArray = Array.from(bulkUserProfiles).map(([_, profile]) => profile); + const options = - bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles)?.map((userProfile) => - userProfileToComboBoxOption(userProfile) - ) ?? []; + bringCurrentUserToFrontAndSort(currentUserProfile, [ + ...userProfiles, + ...bulkUserProfilesAsArray, + ])?.map((userProfile) => userProfileToComboBoxOption(userProfile)) ?? []; const onSearchComboChange = (value: string) => { if (!isEmpty(value)) { @@ -229,15 +247,16 @@ const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { const isLoading = isLoadingForm || isLoadingCurrentUserProfile || + isLoadingBulkGetUserProfiles || isLoadingSuggest || isFetchingSuggest || isUserTyping; - const isDisabled = isLoadingForm || isLoadingCurrentUserProfile; + const isDisabled = isLoadingForm || isLoadingCurrentUserProfile || isLoadingBulkGetUserProfiles; return ( { const onSubmit = jest.fn(); const FormComponent: FC> = ({ children }) => { - const { form } = useForm({ onSubmit }); + const { form } = useForm({ onSubmit }); return (
diff --git a/x-pack/plugins/cases/public/components/create/category.tsx b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx similarity index 93% rename from x-pack/plugins/cases/public/components/create/category.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/category.tsx index 879a8dfb9bbea..d5df6118094e6 100644 --- a/x-pack/plugins/cases/public/components/create/category.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/category.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { useGetCategories } from '../../containers/use_get_categories'; import { CategoryFormField } from '../category/category_form_field'; -import { OptionalFieldLabel } from './optional_field_label'; +import { OptionalFieldLabel } from '../optional_field_label'; interface Props { isLoading: boolean; diff --git a/x-pack/plugins/cases/public/components/templates/connector.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx similarity index 73% rename from x-pack/plugins/cases/public/components/templates/connector.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx index 3222363d6afa4..0f80652c9ac03 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { connectorsMock } from '../../containers/mock'; @@ -46,7 +46,7 @@ const useGetChoicesResponse = { const defaultProps = { connectors: connectorsMock, isLoading: false, - configurationConnectorId: 'none', + isLoadingConnectors: false, }; describe('Connector', () => { @@ -62,7 +62,7 @@ describe('Connector', () => { it('renders correctly', async () => { appMockRender.render( - + ); @@ -73,7 +73,7 @@ describe('Connector', () => { it('renders loading state correctly', async () => { appMockRender.render( - + ); @@ -85,8 +85,8 @@ describe('Connector', () => { it('renders default connector correctly', async () => { appMockRender.render( - - + + ); @@ -96,56 +96,9 @@ 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( - + ); @@ -163,9 +116,9 @@ describe('Connector', () => { ).toBeInTheDocument(); }); - it(`loads connector fields when dropdown selected`, async () => { + it('changes connector correctly', async () => { appMockRender.render( - + ); @@ -187,7 +140,7 @@ describe('Connector', () => { }; appMockRender.render( - + ); @@ -201,7 +154,7 @@ describe('Connector', () => { appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); appMockRender.render( - + ); diff --git a/x-pack/plugins/cases/public/components/templates/connector.tsx b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx similarity index 67% rename from x-pack/plugins/cases/public/components/templates/connector.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/connector.tsx index 4194ee6ed5860..5ed37c262ec17 100644 --- a/x-pack/plugins/cases/public/components/templates/connector.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/connector.tsx @@ -6,33 +6,28 @@ */ import React, { memo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiFormRow } from '@elastic/eui'; import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; +import { schema } from './schema'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { schema } from './schema'; interface Props { connectors: ActionConnector[]; isLoading: boolean; - configurationConnectorId: string; - isEditMode?: boolean; + isLoadingConnectors: boolean; } -const ConnectorComponent: React.FC = ({ - connectors, - isLoading, - configurationConnectorId, - isEditMode = false, -}) => { +const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); + const connector = getConnectorById(connectorId, connectors) ?? null; const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; @@ -42,8 +37,6 @@ const ConnectorComponent: React.FC = ({ connectors, }); - const connector = getConnectorById(connectorId, connectors) ?? null; - if (!hasReadPermissions) { return ( @@ -53,26 +46,27 @@ const ConnectorComponent: React.FC = ({ } return ( - - - - - - - - + + + + + + + + + + ); }; 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 ad44eac1728a5..f2b39b352a964 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 @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { sortBy } from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiFormRow } from '@elastic/eui'; import type { CasesConfigurationUI } from '../../../common/ui'; import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; @@ -15,14 +15,14 @@ import * as i18n from './translations'; interface Props { isLoading: boolean; - setCustomFieldsOptional: boolean; configurationCustomFields: CasesConfigurationUI['customFields']; + setCustomFieldsOptional?: boolean; isEditMode?: boolean; } const CustomFieldsComponent: React.FC = ({ isLoading, - setCustomFieldsOptional, + setCustomFieldsOptional = false, configurationCustomFields, isEditMode, }) => { @@ -55,13 +55,15 @@ const CustomFieldsComponent: React.FC = ({ } return ( - - -

{i18n.ADDITIONAL_FIELDS}

-
- - {customFieldsComponents} -
+ + + +

{i18n.ADDITIONAL_FIELDS}

+
+ + {customFieldsComponents} +
+
); }; diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/description.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx index 5acd5a3b4f5c8..8d841da78b362 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/description.test.tsx @@ -10,7 +10,7 @@ import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Description } from './description'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { MAX_DESCRIPTION_LENGTH } from '../../../common/constants'; diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/case_form_fields/description.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/description.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/description.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx index e80a64a9825b4..5232529e59cef 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/index.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/index.tsx @@ -7,13 +7,13 @@ import React, { memo } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; -import { Title } from '../create/title'; -import { Tags } from '../create/tags'; -import { Category } from '../create/category'; -import { Severity } from '../create/severity'; -import { Description } from '../create/description'; +import { Title } from './title'; +import { Tags } from './tags'; +import { Category } from './category'; +import { Severity } from './severity'; +import { Description } from './description'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { Assignees } from '../create/assignees'; +import { Assignees } from './assignees'; import { CustomFields } from './custom_fields'; import type { CasesConfigurationUI } from '../../containers/types'; @@ -22,6 +22,7 @@ interface Props { configurationCustomFields: CasesConfigurationUI['customFields']; setCustomFieldsOptional?: boolean; isEditMode?: boolean; + draftStorageKey?: string; } const CaseFormFieldsComponent: React.FC = ({ @@ -29,23 +30,18 @@ const CaseFormFieldsComponent: React.FC = ({ configurationCustomFields, setCustomFieldsOptional = false, isEditMode, + draftStorageKey, }) => { const { caseAssignmentAuthorized } = useCasesFeatures(); return ( - + - {caseAssignmentAuthorized ? <Assignees isLoading={isLoading} /> : null} - <Tags isLoading={isLoading} /> - <Category isLoading={isLoading} /> - <Severity isLoading={isLoading} /> - - <Description isLoading={isLoading} /> - + <Description isLoading={isLoading} draftStorageKey={draftStorageKey} /> <CustomFields isLoading={isLoading} setCustomFieldsOptional={setCustomFieldsOptional} diff --git a/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx index a4e1dcfd66cd7..9c501dafff883 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/schema.tsx @@ -8,6 +8,7 @@ import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { CasePostRequest } from '../../../common'; import { MAX_DESCRIPTION_LENGTH, MAX_LENGTH_PER_TAG, @@ -15,20 +16,24 @@ import { MAX_TITLE_LENGTH, } from '../../../common/constants'; import { SEVERITY_TITLE } from '../severity/translations'; -import type { CaseBaseOptionalFields } from '../../../common/types/domain'; +import type { ConnectorTypeFields } from '../../../common/types/domain'; import * as i18n from './translations'; import { validateEmptyTags, validateMaxLength, validateMaxTagsLength } from './utils'; +import { OptionalFieldLabel } from '../optional_field_label'; const { maxLengthField } = fieldValidators; -type CaseFormFieldsProps = Omit< - CaseBaseOptionalFields, - 'customFields' | 'connector' | 'settings' +export type CaseFormFieldsSchemaProps = Omit< + CasePostRequest, + 'connector' | 'settings' | 'owner' | 'customFields' > & { - customFields?: Record<string, string | boolean>; + connectorId: string; + fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; + customFields: Record<string, string | boolean>; }; -export const schema: FormSchema<CaseFormFieldsProps> = { +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { title: { label: i18n.NAME, validations: [ @@ -54,6 +59,7 @@ export const schema: FormSchema<CaseFormFieldsProps> = { tags: { label: i18n.TAGS, helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, validations: [ { validator: ({ value }: { value: string | string[] }) => @@ -84,6 +90,20 @@ export const schema: FormSchema<CaseFormFieldsProps> = { severity: { label: SEVERITY_TITLE, }, - assignees: {}, - category: {}, + assignees: { labelAppend: OptionalFieldLabel }, + category: { + labelAppend: OptionalFieldLabel, + }, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + defaultValue: true, + }, + customFields: {}, + connectorId: { + label: i18n.CONNECTORS, + defaultValue: 'none', + }, + fields: { + defaultValue: null, + }, }; diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/case_form_fields/severity.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/severity.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/severity.tsx diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx similarity index 98% rename from x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx index 8859e3a7e3914..959dcba6d4e7e 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SyncAlertsToggle } from './sync_alerts_toggle'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import { FormTestComponent } from '../../common/test_utils'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; diff --git a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx index 5d4e6bb69f5f0..de9395946ffa7 100644 --- a/x-pack/plugins/cases/public/components/create/sync_alerts_toggle.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/sync_alerts_toggle.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import * as i18n from './translations'; +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx similarity index 95% rename from x-pack/plugins/cases/public/components/create/tags.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx index ed78d78928f0e..78f0cfce49f5f 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.test.tsx @@ -13,12 +13,12 @@ import userEvent from '@testing-library/user-event'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Tags } from './tags'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { MAX_LENGTH_PER_TAG } from '../../../common/constants'; +import type { CaseFormFieldsSchemaProps } from './schema'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/use_get_tags'); @@ -30,7 +30,7 @@ describe('Tags', () => { let appMockRender: AppMockRenderer; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { tags: [] }, schema: { tags: schema.tags, diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/create/tags.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/tags.tsx index 77c4cf7a7ba00..422e89a91afd8 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/tags.tsx @@ -10,7 +10,7 @@ import React, { memo, useMemo } from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { useGetTags } from '../../containers/use_get_tags'; -import * as i18n from './translations'; +import * as i18n from '../create/translations'; interface Props { isLoading: boolean; } diff --git a/x-pack/plugins/cases/public/components/create/title.test.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx similarity index 92% rename from x-pack/plugins/cases/public/components/create/title.test.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx index 382ee67cc494c..73e6c19f90118 100644 --- a/x-pack/plugins/cases/public/components/create/title.test.tsx +++ b/x-pack/plugins/cases/public/components/case_form_fields/title.test.tsx @@ -13,14 +13,14 @@ import { act } from '@testing-library/react'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Title } from './title'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { schema } from '../create/schema'; +import type { CaseFormFieldsSchemaProps } from './schema'; describe('Title', () => { let globalForm: FormHook; const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ + const { form } = useForm<CaseFormFieldsSchemaProps>({ defaultValue: { title: 'My title' }, schema: { title: schema.title, diff --git a/x-pack/plugins/cases/public/components/create/title.tsx b/x-pack/plugins/cases/public/components/case_form_fields/title.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/title.tsx rename to x-pack/plugins/cases/public/components/case_form_fields/title.tsx diff --git a/x-pack/plugins/cases/public/components/case_form_fields/utils.ts b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts index 1ef198ca5fef5..1fde95ff54089 100644 --- a/x-pack/plugins/cases/public/components/case_form_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/case_form_fields/utils.ts @@ -36,7 +36,7 @@ export const validateMaxLength = ({ limit: number; }) => { if ( - (!Array.isArray(value) && value.trim().length > limit) || + (!Array.isArray(value) && isTagCharactersInLimit(value, limit)) || (Array.isArray(value) && value.length > 0 && value.some((item) => isTagCharactersInLimit(item, limit))) diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index 52a40ca065214..d8c98e42f2e38 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -18,17 +18,17 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { ComboBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import * as i18n from '../../tags/translations'; import { useGetTags } from '../../../containers/use_get_tags'; import { Tags } from '../../tags/tags'; import { useCasesContext } from '../../cases_context/use_cases_context'; -import { schemaTags } from '../../create/schema'; +import { schema as createCaseSchema } from '../../create/schema'; -export const schema: FormSchema = { - tags: schemaTags, +export const schema = { + tags: createCaseSchema.tags as FieldConfig<string[]>, }; export interface EditTagsProps { diff --git a/x-pack/plugins/cases/public/components/category/category_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_component.test.tsx index 6eb95600cd58c..e5be97ec20585 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.test.tsx @@ -54,9 +54,9 @@ describe('Category ', () => { render(<CategoryComponent {...defaultProps} />); userEvent.type(screen.getByRole('combobox'), 'new{enter}'); - - expect(onChange).toBeCalledWith('new'); - expect(screen.getByRole('combobox')).toHaveValue('new'); + await waitFor(() => { + expect(onChange).toBeCalledWith('new'); + }); }); it('renders current option list', async () => { @@ -74,7 +74,6 @@ describe('Category ', () => { userEvent.click(screen.getByText('foo')); expect(onChange).toHaveBeenCalledWith('foo'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('foo'); }); it('should call onChange when adding new category', async () => { @@ -84,7 +83,6 @@ describe('Category ', () => { await waitFor(() => { expect(onChange).toHaveBeenCalledWith('hi'); - expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('hi'); }); }); @@ -100,7 +98,7 @@ describe('Category ', () => { userEvent.type(screen.getByRole('combobox'), ' there{enter}'); await waitFor(() => { - expect(onChange).toHaveBeenCalledWith('hi there'); + expect(onChange).toHaveBeenCalledWith('there'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/category/category_component.tsx b/x-pack/plugins/cases/public/components/category/category_component.tsx index ee6f84a244062..f57ba7b36a5ad 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import { ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX } from './translations'; +import type { CaseUI } from '../../../common/ui'; + +export type CategoryField = CaseUI['category'] | undefined; export interface CategoryComponentProps { isLoading: boolean; @@ -26,15 +29,11 @@ export const CategoryComponent: React.FC<CategoryComponentProps> = React.memo( })); }, [availableCategories]); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - category != null ? [{ label: category }] : [] - ); + const selectedOptions = category != null ? [{ label: category }] : []; const onComboChange = useCallback( (currentOptions: Array<EuiComboBoxOptionOption<string>>) => { const value = currentOptions[0]?.label; - - setSelectedOptions(currentOptions); onChange(value); }, [onChange] diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.tsx index 7e51c8fabcb71..f8bb2221ce7d8 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.tsx @@ -15,7 +15,7 @@ import { import { isEmpty } from 'lodash'; import React, { memo } from 'react'; import { MAX_CATEGORY_LENGTH } from '../../../common/constants'; -import type { CaseUI } from '../../../common/ui'; +import type { CategoryField } from './category_component'; import { CategoryComponent } from './category_component'; import { CATEGORY, EMPTY_CATEGORY_VALIDATION_MSG, MAX_LENGTH_ERROR } from './translations'; @@ -25,8 +25,6 @@ interface Props { formRowProps?: Partial<EuiFormRowProps>; } -type CategoryField = CaseUI['category'] | undefined; - const getCategoryConfig = (): FieldConfig<CategoryField> => ({ defaultValue: null, validations: [ diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index a55e67e586458..bf1ace60ced91 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -19,7 +19,7 @@ export const searchURL = '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; const mockConfigurationData = { - closureType: 'close-by-user', + closureType: 'close-by-user' as const, connector: { fields: null, id: 'none', diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 73e6c60a90054..71df212399bc2 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { Suspense, useMemo } from 'react'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, @@ -14,6 +14,7 @@ import { EuiIconTip, EuiSuperSelect, useEuiTheme, + EuiLoadingSpinner, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -31,6 +32,15 @@ export interface Props { appendAddConnectorButton?: boolean; } +const suspendedComponentWithProps = (ComponentToSuspend: React.ComponentType) => { + // eslint-disable-next-line react/display-name + return (props: Record<string, unknown>) => ( + <Suspense fallback={<EuiLoadingSpinner size={'m'} />}> + <ComponentToSuspend {...props} /> + </Suspense> + ); +}; + const ICON_SIZE = 'm'; const noConnectorOption = { @@ -90,6 +100,8 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ const connectorsAsOptions = useMemo(() => { const connectorsFormatted = connectors.reduce( (acc, connector) => { + const iconClass = getConnectorIcon(triggersActionsUi, connector.actionTypeId); + return [ ...acc, { @@ -102,7 +114,11 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({ margin-right: ${euiTheme.size.m}; margin-bottom: 0 !important; `} - type={getConnectorIcon(triggersActionsUi, connector.actionTypeId)} + type={ + typeof iconClass === 'string' + ? iconClass + : suspendedComponentWithProps(iconClass) + } size={ICON_SIZE} /> </EuiFlexItem> 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 33aa2ca42f57a..555f5e6f553b8 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 @@ -49,16 +49,19 @@ describe('CommonFlyout ', () => { isLoading: false, disabled: false, renderHeader: () => <div>{`Flyout header`}</div>, - renderBody: () => <div>{`This is a flyout body`}</div>, }; + const children = ({ onChange }: FlyOutBodyProps<CustomFieldConfiguration>) => ( + <CustomFieldsForm onChange={onChange} initialValue={null} /> + ); + beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); }); it('renders flyout correctly', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); expect(await screen.findByTestId('common-flyout-header')).toBeInTheDocument(); @@ -67,26 +70,28 @@ describe('CommonFlyout ', () => { }); it('renders flyout header correctly', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); expect(await screen.findByText('Flyout header')); }); it('renders loading state correctly', async () => { - appMockRender.render(<CommonFlyout {...{ ...props, isLoading: true }} />); + appMockRender.render( + <CommonFlyout {...{ ...props, isLoading: true }}>{children}</CommonFlyout> + ); expect(await screen.findAllByRole('progressbar')).toHaveLength(2); }); it('renders disable state correctly', async () => { - appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }} />); + appMockRender.render(<CommonFlyout {...{ ...props, disabled: true }}>{children}</CommonFlyout>); expect(await screen.findByTestId('common-flyout-cancel')).toBeDisabled(); expect(await screen.findByTestId('common-flyout-save')).toBeDisabled(); }); it('calls onCloseFlyout on cancel', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); userEvent.click(await screen.findByTestId('common-flyout-cancel')); @@ -96,7 +101,7 @@ describe('CommonFlyout ', () => { }); it('calls onCloseFlyout on close', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); userEvent.click(await screen.findByTestId('euiFlyoutCloseButton')); @@ -106,7 +111,7 @@ describe('CommonFlyout ', () => { }); it('does not call onSaveField when not valid data', async () => { - appMockRender.render(<CommonFlyout {...props} />); + appMockRender.render(<CommonFlyout {...props}>{children}</CommonFlyout>); userEvent.click(await screen.findByTestId('common-flyout-save')); @@ -118,13 +123,8 @@ describe('CommonFlyout ', () => { <CustomFieldsForm onChange={onChange} initialValue={null} /> ); - const newProps = { - ...props, - renderBody, - }; - it('should render custom field form in flyout', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); expect(await screen.findByTestId('custom-field-label-input')).toBeInTheDocument(); expect(await screen.findByTestId('custom-field-type-selector')).toBeInTheDocument(); @@ -133,13 +133,13 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField form correctly', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: false, @@ -149,7 +149,7 @@ describe('CommonFlyout ', () => { }); it('shows error if field label is too long', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); @@ -164,13 +164,13 @@ describe('CommonFlyout ', () => { describe('Text custom field', () => { it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: false, @@ -180,7 +180,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with correct params when a custom field is NOT required and has a default value', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.paste( @@ -190,7 +190,7 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: false, @@ -201,7 +201,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct params when a custom field is required', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); @@ -212,7 +212,7 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: true, @@ -223,14 +223,14 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct params when a custom field is required and the defaultValue is missing', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), label: 'Summary', required: true, @@ -247,10 +247,9 @@ describe('CommonFlyout ', () => { const modifiedProps = { ...props, data: customFieldsConfigurationMock[0], - renderBody: newRenderBody, }; - appMockRender.render(<CommonFlyout {...modifiedProps} />); + appMockRender.render(<CommonFlyout {...modifiedProps}>{newRenderBody}</CommonFlyout>); expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( 'value', @@ -265,7 +264,7 @@ describe('CommonFlyout ', () => { }); it('shows an error if default value is too long', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('custom-field-label-input'), 'Summary'); userEvent.click(await screen.findByTestId('text-custom-field-required')); @@ -284,7 +283,7 @@ describe('CommonFlyout ', () => { describe('Toggle custom field', () => { it('calls onSaveField with correct params when a custom field is NOT required', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { target: { value: CustomFieldTypes.TOGGLE }, @@ -305,7 +304,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField with the correct default value when a custom field is required', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); fireEvent.change(await screen.findByTestId('custom-field-type-selector'), { target: { value: CustomFieldTypes.TOGGLE }, @@ -331,12 +330,7 @@ describe('CommonFlyout ', () => { <CustomFieldsForm onChange={onChange} initialValue={customFieldsConfigurationMock[1]} /> ); - const modifiedProps = { - ...props, - renderBody: newRenderBody, - }; - - appMockRender.render(<CommonFlyout {...modifiedProps} />); + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( 'value', @@ -380,13 +374,8 @@ describe('CommonFlyout ', () => { /> ); - const newProps = { - ...props, - renderBody, - }; - it('should render template form in flyout', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); expect(await screen.findByTestId('template-creation-form-steps')).toBeInTheDocument(); @@ -409,24 +398,19 @@ describe('CommonFlyout ', () => { ], }; - const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( - <TemplateForm - initialValue={templatesConfigurationMock[3]} - connectors={[]} - currentConfiguration={newConfiguration} - onChange={onChange} - /> - ); - appMockRender = createAppMockRenderer({ license }); appMockRender.render( - <CommonFlyout - {...{ - ...newProps, - renderBody: newRenderBody, - }} - /> + <CommonFlyout {...props}> + {({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( + <TemplateForm + initialValue={templatesConfigurationMock[3]} + connectors={[]} + currentConfiguration={newConfiguration} + onChange={onChange} + /> + )} + </CommonFlyout> ); // template fields @@ -471,7 +455,7 @@ describe('CommonFlyout ', () => { }); it('calls onSaveField form correctly', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); userEvent.paste( @@ -485,18 +469,23 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'Template description', name: 'Template name', - templateDescription: 'Template description', - templateTags: ['foo'], - title: '', - description: '', - tags: [], - severity: '', - category: null, - connectorId: 'none', - syncAlerts: true, + tags: ['foo'], }); }); }); @@ -508,6 +497,7 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', description: 'test description', + caseFields: null, }} connectors={[]} currentConfiguration={currentConfiguration} @@ -515,14 +505,7 @@ describe('CommonFlyout ', () => { /> ); - appMockRender.render( - <CommonFlyout - {...{ - ...newProps, - renderBody: newRenderBody, - }} - /> - ); + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); const caseTitle = await screen.findByTestId('caseTitle'); userEvent.paste(within(caseTitle).getByTestId('input'), 'Case using template'); @@ -539,18 +522,26 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', - templateTags: [], - title: 'Case using template', - description: 'This is a case description', - category: 'new', + description: 'test description', tags: [], - severity: '', - connectorId: 'none', - syncAlerts: true, + caseFields: { + title: 'Case using template', + description: 'This is a case description', + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, }); }); }); @@ -563,6 +554,7 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', description: 'test description', + caseFields: null, }} connectors={[]} currentConfiguration={newConfig} @@ -570,12 +562,7 @@ describe('CommonFlyout ', () => { /> ); - const modifiedProps = { - ...props, - renderBody: newRenderBody, - }; - - appMockRender.render(<CommonFlyout {...modifiedProps} />); + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); const textCustomField = await screen.findByTestId( `${customFieldsConfigurationMock[0].key}-text-create-custom-field` @@ -587,23 +574,38 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', - templateTags: [], - title: '', + description: 'test description', 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, + caseFields: { + connector: { + id: 'none', + name: 'none', + type: '.none', + fields: null, + }, + settings: { + syncAlerts: true, + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is a sample text!', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], }, }); }); @@ -611,14 +613,17 @@ describe('CommonFlyout ', () => { it('calls onSaveField form with connector fields correctly', async () => { useGetChoicesMock.mockReturnValue(useGetChoicesResponse); + + const connector = { + id: 'servicenow-1', + name: 'My SN connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }; + const newConfig = { ...currentConfiguration, - connector: { - id: 'servicenow-1', - name: 'My SN connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + connector, }; const newRenderBody = ({ onChange }: FlyOutBodyProps<TemplateFormProps>) => ( @@ -627,6 +632,7 @@ describe('CommonFlyout ', () => { key: 'random_key', name: 'Template 1', description: 'test description', + caseFields: { connector }, }} connectors={connectorsMock} currentConfiguration={newConfig} @@ -634,41 +640,36 @@ describe('CommonFlyout ', () => { /> ); - const modifiedProps = { - ...props, - renderBody: newRenderBody, - }; - - appMockRender.render(<CommonFlyout {...modifiedProps} />); + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); - userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); - userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).toBeCalledWith({ + expect(props.onSaveField).toBeCalledWith({ key: 'random_key', name: 'Template 1', - templateDescription: 'test description', - templateTags: [], - title: '', + description: 'test description', tags: [], - severity: '', - description: '', - category: null, - connectorId: 'servicenow-1', - fields: { - category: 'software', - urgency: '1', - impact: '', - severity: '', - subcategory: null, + caseFields: { + customFields: [], + connector: { + ...connector, + fields: { + urgency: '1', + severity: null, + impact: null, + category: 'software', + subcategory: null, + }, + }, + settings: { + syncAlerts: true, + }, }, - syncAlerts: true, }); }); }); @@ -702,14 +703,7 @@ describe('CommonFlyout ', () => { /> ); - appMockRender.render( - <CommonFlyout - {...{ - ...newProps, - renderBody: newRenderBody, - }} - /> - ); + appMockRender.render(<CommonFlyout {...props}>{newRenderBody}</CommonFlyout>); userEvent.clear(await screen.findByTestId('template-name-input')); userEvent.paste(await screen.findByTestId('template-name-input'), 'Template name'); @@ -727,27 +721,39 @@ describe('CommonFlyout ', () => { 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', + expect(props.onSaveField).toBeCalledWith({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'Updated custom field value', + }, + ], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Updated case using template', }, - description: 'case desc', - category: null, + description: 'This is a fourth test template', 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', + tags: ['foo', 'bar'], }); }); }); it('shows error when template name is empty', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); userEvent.paste( await screen.findByTestId('template-description-input'), @@ -757,14 +763,14 @@ describe('CommonFlyout ', () => { userEvent.click(await screen.findByTestId('common-flyout-save')); await waitFor(() => { - expect(newProps.onSaveField).not.toHaveBeenCalled(); + expect(props.onSaveField).not.toHaveBeenCalled(); }); expect(await screen.findByText('A Template name is required.')).toBeInTheDocument(); }); it('shows error if template name is too long', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); const message = 'z'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); @@ -776,7 +782,7 @@ describe('CommonFlyout ', () => { }); it('shows error if template description is too long', async () => { - appMockRender.render(<CommonFlyout {...newProps} />); + appMockRender.render(<CommonFlyout {...props}>{renderBody}</CommonFlyout>); const message = 'z'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx index 130b48626b3ae..37d16d01e5681 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -21,33 +21,33 @@ import type { FormHook, FormData } from '@kbn/es-ui-shared-plugin/static/forms/h import * as i18n from './translations'; -export interface FormState<T extends FormData = FormData> { +export interface FormState<T extends FormData = FormData, I extends FormData = T> { isValid: boolean | undefined; - submit: FormHook<T>['submit']; + submit: FormHook<T, I>['submit']; } -export interface FlyOutBodyProps<T extends FormData = FormData> { - onChange: (state: FormState<T>) => void; +export interface FlyOutBodyProps<T extends FormData = FormData, I extends FormData = T> { + onChange: (state: FormState<T, I>) => void; } -export interface FlyoutProps<T extends FormData = FormData> { +export interface FlyoutProps<T extends FormData = FormData, I extends FormData = T> { disabled: boolean; isLoading: boolean; onCloseFlyout: () => void; - onSaveField: (data: T) => void; + onSaveField: (data: I) => void; renderHeader: () => React.ReactNode; - renderBody: ({ onChange }: FlyOutBodyProps<T>) => React.ReactNode; + children: ({ onChange }: FlyOutBodyProps<T, I>) => React.ReactNode; } -export const CommonFlyout = <T extends FormData = FormData>({ +export const CommonFlyout = <T extends FormData = FormData, I extends FormData = T>({ onCloseFlyout, onSaveField, isLoading, disabled, renderHeader, - renderBody, -}: FlyoutProps<T>) => { - const [formState, setFormState] = useState<FormState<T>>({ + children, +}: FlyoutProps<T, I>) => { + const [formState, setFormState] = useState<FormState<T, I>>({ isValid: undefined, submit: async () => ({ isValid: false, @@ -61,10 +61,29 @@ export const CommonFlyout = <T extends FormData = FormData>({ const { isValid, data } = await submit(); if (isValid) { - onSaveField(data as T); + /** + * The serializer transforms the data + * from the form format to the backend + * format. The I generic is the correct + * format of the data. + */ + onSaveField(data as unknown as I); } }, [onSaveField, submit]); + /** + * The children will call setFormState which in turn will make the parent + * to rerender which in turn will rerender the children etc. + * To avoid an infinitive loop we need to memoize the children. + */ + const memoizedChildren = useMemo( + () => + children({ + onChange: setFormState, + }), + [children] + ); + return ( <EuiFlyout onClose={onCloseFlyout} data-test-subj="common-flyout"> <EuiFlyoutHeader hasBorder data-test-subj="common-flyout-header"> @@ -72,11 +91,7 @@ export const CommonFlyout = <T extends FormData = FormData>({ <h3 id="flyoutTitle">{renderHeader()}</h3> </EuiTitle> </EuiFlyoutHeader> - <EuiFlyoutBody> - {renderBody({ - onChange: setFormState, - })} - </EuiFlyoutBody> + <EuiFlyoutBody>{memoizedChildren}</EuiFlyoutBody> <EuiFlyoutFooter data-test-subj={'common-flyout-footer'}> <EuiFlexGroup justifyContent="flexStart"> <EuiFlexItem grow={false}> 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 c625fa6f1324e..b424b2ca62fc0 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 @@ -79,7 +79,11 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { @@ -127,7 +131,11 @@ describe('ConfigureCases', () => { }, })); - useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); + useGetConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + data: [], + isLoading: false, + })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases />, { wrappingComponent: TestProviders, @@ -940,6 +948,8 @@ describe('ConfigureCases', () => { }); it('should delete a template', async () => { + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, data: { @@ -974,6 +984,7 @@ describe('ConfigureCases', () => { { ...templatesConfigurationMock[1] }, { ...templatesConfigurationMock[2] }, { ...templatesConfigurationMock[3] }, + { ...templatesConfigurationMock[4] }, ], id: '', version: '', 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 c9719bb20af0b..1003a10646e8c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable complexity */ + import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { css } from '@emotion/react'; @@ -41,13 +43,11 @@ import { CustomFields } from '../custom_fields'; import { CommonFlyout } from './flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; -import { transformCustomFieldsData } from '../custom_fields/utils'; import { useLicense } from '../../common/use_license'; import { Templates } from '../templates'; import type { TemplateFormProps } from '../templates/types'; import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; -import { getTemplateSerializedData } from '../templates/utils'; const sectionWrapperCss = css` box-sizing: content-box; @@ -399,43 +399,8 @@ export const ConfigureCases: React.FC = React.memo(() => { }, [setFlyOutVisibility, setTemplateToEdit]); const onTemplateSave = useCallback( - (data: TemplateFormProps) => { - const serializedData = getTemplateSerializedData(data); - const { - connectorId, - fields, - customFields: templateCustomFields, - syncAlerts = false, - key, - name, - templateTags, - templateDescription, - ...otherCaseFields - } = serializedData; - - const transformedCustomFields = templateCustomFields - ? transformCustomFieldsData(templateCustomFields, customFields) - : []; - const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; - - const transformedConnector = templateConnector - ? normalizeActionConnector(templateConnector, fields) - : getNoneConnector(); - - const transformedData: TemplateConfiguration = { - key, - name, - description: templateDescription, - tags: templateTags ?? [], - caseFields: { - ...otherCaseFields, - connector: transformedConnector, - customFields: transformedCustomFields, - settings: { syncAlerts }, - }, - }; - - const updatedTemplates = addOrReplaceField(templates, transformedData); + (data: TemplateConfiguration) => { + const updatedTemplates = addOrReplaceField(templates, data); persistCaseConfigure({ connector, @@ -454,7 +419,6 @@ export const ConfigureCases: React.FC = React.memo(() => { configurationId, configurationVersion, connector, - connectors, customFields, templates, persistCaseConfigure, @@ -474,15 +438,16 @@ export const ConfigureCases: React.FC = React.memo(() => { onCloseFlyout={onCloseCustomFieldFlyout} onSaveField={onCustomFieldSave} renderHeader={() => <span>{i18n.ADD_CUSTOM_FIELD}</span>} - renderBody={({ onChange }) => ( + > + {({ onChange }) => ( <CustomFieldsForm onChange={onChange} initialValue={customFieldToEdit} /> )} - /> + </CommonFlyout> ) : null; const AddOrEditTemplateFlyout = flyOutVisibility?.type === 'template' && flyOutVisibility?.visible ? ( - <CommonFlyout<TemplateFormProps> + <CommonFlyout<TemplateFormProps, TemplateConfiguration> isLoading={loadingCaseConfigure || isPersistingConfiguration} disabled={ !permissions.create || @@ -493,7 +458,8 @@ export const ConfigureCases: React.FC = React.memo(() => { onCloseFlyout={onCloseTemplateFlyout} onSaveField={onTemplateSave} renderHeader={() => <span>{i18n.CREATE_TEMPLATE}</span>} - renderBody={({ onChange }) => ( + > + {({ onChange }) => ( <TemplateForm initialValue={templateToEdit} connectors={connectors ?? []} @@ -502,7 +468,7 @@ export const ConfigureCases: React.FC = React.memo(() => { onChange={onChange} /> )} - /> + </CommonFlyout> ) : null; return ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/utils.ts b/x-pack/plugins/cases/public/components/configure_cases/utils.ts index 2177ea7af81d9..a46b85f756941 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/utils.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/utils.ts @@ -51,7 +51,7 @@ export const setThirdPartyToMapping = ( export const getNoneConnector = (): CaseConnector => ({ id: 'none', name: 'none', - type: ConnectorTypes.none, + type: ConnectorTypes.none as const, fields: null, }); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx index 485135bcd3e4c..57772c0b177b7 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/case_fields.tsx @@ -27,7 +27,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co const [{ fields }] = useFormData<{ fields: JiraFieldsType }>(); const { http } = useKibana().services; - const { issueType, parent } = fields ?? {}; + const { issueType } = fields ?? {}; const { isLoading: isLoadingIssueTypesData, @@ -107,7 +107,7 @@ const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps> = ({ co <div style={{ display: hasParent ? 'block' : 'none' }}> <EuiFlexGroup> <EuiFlexItem> - <SearchIssues actionConnector={connector} currentParent={parent} /> + <SearchIssues actionConnector={connector} /> </EuiFlexItem> </EuiFlexGroup> <EuiSpacer size="m" /> diff --git a/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/search_issues.tsx index 3193777389c7f..c4089c7f14c60 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,105 +5,141 @@ * 2.0. */ -import React, { useState, memo, useRef } from 'react'; +import React, { useState, memo } from 'react'; +import { isEmpty } from 'lodash'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox, EuiFormRow } from '@elastic/eui'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { getFieldValidityAndErrorMessage, UseField, + useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useIsUserTyping } from '../../../common/use_is_user_typing'; 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 FieldProps { + field: FieldHook<string>; + options: Array<EuiComboBoxOptionOption<string>>; + isLoading: boolean; + onSearchComboChange: (value: string) => void; +} + interface Props { actionConnector?: ActionConnector; - currentParent: string | null; } -const SearchIssuesComponent: React.FC<Props> = ({ actionConnector, currentParent }) => { - const [query, setQuery] = useState<string | null>(null); - const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>( - [] +const SearchIssuesFieldComponent: React.FC<FieldProps> = ({ + field, + options, + isLoading, + onSearchComboChange, +}) => { + const { value: parent } = field; + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const selectedOptions = [parent] + .map((currentParent: string) => { + const selectedParent = options.find((issue) => issue.value === currentParent); + + if (selectedParent) { + return selectedParent; + } + + return null; + }) + .filter((value): value is EuiComboBoxOptionOption<string> => value != null); + + const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { + field.setValue(changedOptions.length ? changedOptions[0].value ?? '' : ''); + }; + + return ( + <EuiFormRow + id="indexConnectorSelectSearchBox" + fullWidth + label={i18n.PARENT_ISSUE} + isInvalid={isInvalid} + error={errorMessage} + > + <EuiComboBox + fullWidth + singleSelection + async + placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} + aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} + isLoading={isLoading} + isInvalid={isInvalid} + noSuggestions={!options.length} + options={options} + data-test-subj="search-parent-issues" + data-testid="search-parent-issues" + selectedOptions={selectedOptions} + onChange={onChangeComboBox} + onSearchChange={onSearchComboChange} + /> + </EuiFormRow> ); +}; +SearchIssuesFieldComponent.displayName = 'SearchIssuesField'; + +const SearchIssuesComponent: React.FC<Props> = ({ actionConnector }) => { const { http } = useKibana().services; - const isFirstRender = useRef(true); + const [{ fields }] = useFormData<{ fields?: { parent: string } }>({ + watch: ['fields.parent'], + }); + + const [query, setQuery] = useState<string | null>(null); + const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const { isFetching: isLoadingIssues, data: issuesData } = useGetIssues({ http, actionConnector, query, + onDebounce, }); const { isFetching: isLoadingIssue, data: issueData } = useGetIssue({ http, actionConnector, - id: currentParent ?? '', + id: fields?.parent ?? '', }); const issues = issuesData?.data ?? []; + const issue = issueData?.data ? [issueData.data] : []; - const options = issues.map((issue) => ({ label: issue.title, value: issue.key })); + const onSearchComboChange = (value: string) => { + if (!isEmpty(value)) { + setQuery(value); + } - const issue = issueData?.data ?? null; + onContentChange(value); + }; - if ( - isFirstRender.current && - !isLoadingIssue && - issue && - !selectedOptions.find((option) => option.value === issue.key) - ) { - setSelectedOptions([{ label: issue.title, value: issue.key }]); - } + const isLoading = isUserTyping || isLoadingIssues || isLoadingIssue; + const options = [...issues, ...issue].map((_issue) => ({ + label: _issue.title, + value: _issue.key, + })); return ( - <UseField path="fields.parent"> - {(field) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const onSearchChange = (searchVal: string) => { - setQuery(searchVal); - }; - - const onChangeComboBox = (changedOptions: Array<EuiComboBoxOptionOption<string>>) => { - setSelectedOptions(changedOptions); - field.setValue(changedOptions.length ? changedOptions[0].value : ''); - isFirstRender.current = false; - }; - - return ( - <EuiFormRow - id="indexConnectorSelectSearchBox" - fullWidth - label={i18n.PARENT_ISSUE} - isInvalid={isInvalid} - error={errorMessage} - > - <EuiComboBox - fullWidth - singleSelection - async - placeholder={i18n.SEARCH_ISSUES_PLACEHOLDER} - aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL} - isLoading={isLoadingIssues} - isInvalid={isInvalid} - noSuggestions={!options.length} - options={options} - data-test-subj="search-parent-issues" - data-testid="search-parent-issues" - selectedOptions={selectedOptions} - onChange={onChangeComboBox} - onSearchChange={onSearchChange} - /> - </EuiFormRow> - ); + <UseField<string> + path="fields.parent" + component={SearchIssuesFieldComponent} + componentProps={{ + isLoading, + onSearchComboChange, + options, }} - </UseField> + /> ); }; + SearchIssuesComponent.displayName = 'SearchIssues'; export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx index 037fcc6bb8d8e..01f4ad0a3edb3 100644 --- a/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/cases/public/components/connectors/jira/use_get_issues.tsx @@ -10,7 +10,8 @@ import useDebounce from 'react-use/lib/useDebounce'; 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 { isEmpty, noop } from 'lodash'; +import { SEARCH_DEBOUNCE_MS } from '../../../../common/constants'; import type { ActionConnector } from '../../../../common/types/domain'; import { getIssues } from './api'; import type { Issues } from './types'; @@ -23,16 +24,16 @@ interface Props { http: HttpSetup; query: string | null; actionConnector?: ActionConnector; + onDebounce?: () => void; } -const SEARCH_DEBOUNCE_MS = 500; - -export const useGetIssues = ({ http, actionConnector, query }: Props) => { +export const useGetIssues = ({ http, actionConnector, query, onDebounce = noop }: Props) => { const [debouncedQuery, setDebouncedQuery] = useState(query); useDebounce( () => { setDebouncedQuery(query); + onDebounce(); }, SEARCH_DEBOUNCE_MS, [query] diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx deleted file mode 100644 index 1cf7c82075136..0000000000000 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { mount } from 'enzyme'; -import { act, waitFor } from '@testing-library/react'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiComboBox } from '@elastic/eui'; - -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock } from '../../containers/mock'; -import { Connector } from './connector'; -import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; -import { useGetSeverity } from '../connectors/resilient/use_get_severity'; -import { useGetChoices } from '../connectors/servicenow/use_get_choices'; -import { incidentTypes, severity, choices } from '../connectors/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; -import type { AppMockRenderer } from '../../common/mock'; -import { - noConnectorsCasePermission, - createAppMockRenderer, - TestProviders, -} from '../../common/mock'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import { useCaseConfigureResponse } from '../configure_cases/__mock__'; - -jest.mock('../connectors/resilient/use_get_incident_types'); -jest.mock('../connectors/resilient/use_get_severity'); -jest.mock('../connectors/servicenow/use_get_choices'); -jest.mock('../../containers/configure/use_get_case_configuration'); - -const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; -const useGetSeverityMock = useGetSeverity as jest.Mock; -const useGetChoicesMock = useGetChoices as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; - -const useGetIncidentTypesResponse = { - isLoading: false, - incidentTypes, -}; - -const useGetSeverityResponse = { - isLoading: false, - severity, -}; - -const useGetChoicesResponse = { - isLoading: false, - choices, -}; - -const defaultProps = { - connectors: connectorsMock, - isLoading: false, - isLoadingConnectors: false, -}; - -describe('Connector', () => { - let appMockRender: AppMockRenderer; - let globalForm: FormHook; - - const MockHookWrapperComponent: FC<PropsWithChildren<unknown>> = ({ children }) => { - const { form } = useForm<FormProps>({ - defaultValue: { connectorId: connectorsMock[0].id, fields: null }, - schema: { - connectorId: schema.connectorId, - fields: schema.fields, - }, - }); - - globalForm = form; - - return <Form form={form}>{children}</Form>; - }; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); - useGetSeverityMock.mockReturnValue(useGetSeverityResponse); - useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); - }); - - it('it renders', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - // Selected connector is set to none so no fields should be displayed - expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy(); - }); - - it('it is disabled and loading when isLoadingConnectors=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoadingConnectors: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it('it is disabled and loading when isLoading=true', async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...{ ...defaultProps, isLoading: true }} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading') - ).toEqual(true); - expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual( - true - ); - }); - - it(`it should change connector`, async () => { - const wrapper = mount( - <TestProviders> - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - </TestProviders> - ); - - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); - }); - - act(() => { - ( - wrapper.find(EuiComboBox).props() as unknown as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - } - ).onChange([{ value: '19', label: 'Denial of Service' }]); - }); - - act(() => { - wrapper - .find('select[data-test-subj="severitySelect"]') - .first() - .simulate('change', { - target: { value: '4' }, - }); - }); - - await waitFor(() => { - expect(globalForm.getFormData()).toEqual({ - connectorId: 'resilient-2', - fields: { incidentTypes: ['19'], severityCode: '4' }, - }); - }); - }); - - it('shows the actions permission message if the user does not have read access to actions', async () => { - appMockRender.coreStart.application.capabilities = { - ...appMockRender.coreStart.application.capabilities, - actions: { save: false, show: false }, - }; - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); - - it('shows the actions permission message if the user does not have access to case connector', async () => { - appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - - const result = appMockRender.render( - <MockHookWrapperComponent> - <Connector {...defaultProps} /> - </MockHookWrapperComponent> - ); - expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('caseConnectors')).toBe(null); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx deleted file mode 100644 index 39e04f7bc0be3..0000000000000 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; - -import type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { ActionConnector } from '../../../common/types/domain'; -import { ConnectorSelector } from '../connector_selector/form'; -import { ConnectorFieldsForm } from '../connectors/fields_form'; -import { schema } from './schema'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; -import { getConnectorById, getConnectorsFormValidators } from '../utils'; -import { useApplicationCapabilities } from '../../common/lib/kibana'; -import * as i18n from '../../common/translations'; -import { useCasesContext } from '../cases_context/use_cases_context'; - -interface Props { - connectors: ActionConnector[]; - isLoading: boolean; - isLoadingConnectors: boolean; -} - -const ConnectorComponent: React.FC<Props> = ({ connectors, isLoading, isLoadingConnectors }) => { - const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); - const connector = getConnectorById(connectorId, connectors) ?? null; - - const { - data: { connector: configurationConnector }, - } = useGetCaseConfiguration(); - - const { actions } = useApplicationCapabilities(); - const { permissions } = useCasesContext(); - const hasReadPermissions = permissions.connectors && actions.read; - - const defaultConnectorId = useMemo(() => { - return connectors.some((c) => c.id === configurationConnector.id) - ? configurationConnector.id - : 'none'; - }, [configurationConnector.id, connectors]); - - const connectorIdConfig = getConnectorsFormValidators({ - config: schema.connectorId as FieldConfig, - connectors, - }); - - if (!hasReadPermissions) { - return ( - <EuiText data-test-subj="create-case-connector-permissions-error-msg" size="s"> - <span>{i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG}</span> - </EuiText> - ); - } - - return ( - <EuiFlexGroup> - <EuiFlexItem> - <UseField - path="connectorId" - config={connectorIdConfig} - component={ConnectorSelector} - defaultValue={defaultConnectorId} - componentProps={{ - connectors, - dataTestSubj: 'caseConnectors', - disabled: isLoading || isLoadingConnectors, - idAria: 'caseConnectors', - isLoading: isLoading || isLoadingConnectors, - }} - /> - </EuiFlexItem> - <EuiFlexItem> - <ConnectorFieldsForm connector={connector} /> - </EuiFlexItem> - </EuiFlexGroup> - ); -}; - -ConnectorComponent.displayName = 'ConnectorComponent'; - -export const Connector = memo(ConnectorComponent); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx deleted file mode 100644 index 8ab517c497cde..0000000000000 --- a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { FormTestComponent } from '../../common/test_utils'; -import { customFieldsConfigurationMock } from '../../containers/mock'; -import { CustomFields } from './custom_fields'; -import * as i18n from './translations'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; - -jest.mock('../../containers/configure/use_get_all_case_configurations'); - -const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; - -describe('CustomFields', () => { - let appMockRender: AppMockRenderer; - const onSubmit = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: customFieldsConfigurationMock, - }, - ], - })); - }); - - it('renders correctly', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CustomFields isLoading={false} /> - </FormTestComponent> - ); - - expect(await screen.findByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); - - for (const item of customFieldsConfigurationMock) { - expect( - await screen.findByTestId(`${item.key}-${item.type}-create-custom-field`) - ).toBeInTheDocument(); - } - }); - - it('should not show the custom fields if the configuration is empty', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [], - }, - ], - })); - - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CustomFields isLoading={false} /> - </FormTestComponent> - ); - - expect(screen.queryByText(i18n.ADDITIONAL_FIELDS)).not.toBeInTheDocument(); - expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); - }); - - it('should sort the custom fields correctly', async () => { - const reversedCustomFieldsConfiguration = [...customFieldsConfigurationMock].reverse(); - - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: reversedCustomFieldsConfiguration, - }, - ], - })); - - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CustomFields isLoading={false} /> - </FormTestComponent> - ); - - const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields'); - - const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); - - expect(customFields).toHaveLength(4); - - expect(customFields[0]).toHaveTextContent('My test label 1'); - expect(customFields[1]).toHaveTextContent('My test label 2'); - expect(customFields[2]).toHaveTextContent('My test label 3'); - expect(customFields[3]).toHaveTextContent('My test label 4'); - }); - - it('should update the custom fields', async () => { - appMockRender = createAppMockRenderer(); - - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CustomFields isLoading={false} /> - </FormTestComponent> - ); - - const textField = customFieldsConfigurationMock[2]; - const toggleField = customFieldsConfigurationMock[3]; - - userEvent.type( - await screen.findByTestId(`${textField.key}-${textField.type}-create-custom-field`), - 'hello' - ); - userEvent.click( - await screen.findByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) - ); - - userEvent.click(await screen.findByText('Submit')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toHaveBeenCalledWith( - { - customFields: { - [customFieldsConfigurationMock[0].key]: customFieldsConfigurationMock[0].defaultValue, - [customFieldsConfigurationMock[1].key]: customFieldsConfigurationMock[1].defaultValue, - [textField.key]: 'hello', - [toggleField.key]: true, - }, - }, - true - ); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx deleted file mode 100644 index 28cebde65db27..0000000000000 --- a/x-pack/plugins/cases/public/components/create/custom_fields.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import { sortBy } from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; - -import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { CasesConfigurationUI } from '../../../common/ui'; -import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; -import * as i18n from './translations'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; -import { getConfigurationByOwner } from '../../containers/configure/utils'; - -interface Props { - isLoading: boolean; -} - -const CustomFieldsComponent: React.FC<Props> = ({ isLoading }) => { - const { owner } = useCasesContext(); - const [{ selectedOwner }] = useFormData<{ selectedOwner: string }>({ watch: ['selectedOwner'] }); - const { data: configurations, isLoading: isLoadingCaseConfiguration } = - useGetAllCaseConfigurations(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const customFieldsConfiguration = useMemo( - () => - getConfigurationByOwner({ - configurations, - owner: configurationOwner, - }).customFields ?? [], - [configurations, configurationOwner] - ); - - const sortedCustomFields = useMemo( - () => sortCustomFieldsByLabel(customFieldsConfiguration), - [customFieldsConfiguration] - ); - - const customFieldsComponents = sortedCustomFields.map( - (customField: CasesConfigurationUI['customFields'][number]) => { - const customFieldFactory = customFieldsBuilderMap[customField.type]; - const customFieldType = customFieldFactory().build(); - - const CreateComponent = customFieldType.Create; - - return ( - <CreateComponent - isLoading={isLoading || isLoadingCaseConfiguration} - customFieldConfiguration={customField} - key={customField.key} - /> - ); - } - ); - - if (!customFieldsConfiguration.length) { - return null; - } - - return ( - <EuiFlexGroup direction="column" gutterSize="s"> - <EuiText size="m"> - <h3>{i18n.ADDITIONAL_FIELDS}</h3> - </EuiText> - <EuiSpacer size="xs" /> - <EuiFlexItem data-test-subj="create-case-custom-fields">{customFieldsComponents}</EuiFlexItem> - </EuiFlexGroup> - ); -}; - -CustomFieldsComponent.displayName = 'CustomFields'; - -export const CustomFields = React.memo(CustomFieldsComponent); - -const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { - return sortBy(configCustomFields, (configCustomField) => { - return configCustomField.label; - }); -}; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index b5b3f7bf7b677..885e25e959ac9 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -5,48 +5,44 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; import React from 'react'; -import { mount } from 'enzyme'; -import { act, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { within, fireEvent, waitFor, screen } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; -import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import { + connectorsMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; import type { CreateCaseFormProps } from './form'; import { CreateCaseForm } from './form'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetAllCaseConfigurationsResponse } from '../configure_cases/__mock__'; -import { TestProviders } from '../../common/mock'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); jest.mock('../../containers/configure/use_get_all_case_configurations'); +jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); +jest.mock('../../containers/user_profiles/use_get_current_user_profile'); jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); jest.mock('../app/use_available_owners'); const useGetTagsMock = useGetTags as jest.Mock; -const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; +const useGetSupportedActionConnectorsMock = useGetSupportedActionConnectors as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +const useSuggestUserProfilesMock = useSuggestUserProfiles as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; const casesFormProps: CreateCaseFormProps = { onCancel: jest.fn(), @@ -54,36 +50,18 @@ const casesFormProps: CreateCaseFormProps = { }; describe('CreateCaseForm', () => { - let globalForm: FormHook; - const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; - - const MockHookWrapperComponent: FC< - PropsWithChildren<{ - testProviderProps?: unknown; - }> - > = ({ children, testProviderProps = {} }) => { - const { form } = useForm<FormProps>({ - defaultValue: initialCaseValue, - options: { stripEmptyFields: false }, - schema, - }); - - globalForm = form; - - return ( - // @ts-expect-error ts upgrade v4.7.4 - <TestProviders {...testProviderProps}> - <Form form={form}>{children}</Form> - </TestProviders> - ); - }; + const draftStorageKey = 'cases.caseView.createCase.description.markdownEditor'; + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']); useGetTagsMock.mockReturnValue({ data: ['test'] }); - useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); + useGetSupportedActionConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); + useSuggestUserProfilesMock.mockReturnValue({ data: userProfiles, isLoading: false }); + useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); }); afterEach(() => { @@ -91,136 +69,86 @@ describe('CreateCaseForm', () => { }); it('renders with steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('case-creation-form-steps')).toBeInTheDocument(); }); it('renders without steps', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} withSteps={false} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} withSteps={false} />); - expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy(); + expect(screen.queryByText('case-creation-form-steps')).not.toBeInTheDocument(); }); it('renders all form fields except case selection', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(screen.queryByText('caseOwnerSelector')).not.toBeInTheDocument(); }); it('renders all form fields including case selection if has permissions and no owner', async () => { - const wrapper = mount( - <MockHookWrapperComponent testProviderProps={{ owner: [] }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="categories-list"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy(); + appMockRenderer = createAppMockRenderer({ owner: [] }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + expect(await screen.findByTestId('caseTitle')).toBeInTheDocument(); + expect(await screen.findByTestId('caseTags')).toBeInTheDocument(); + expect(await screen.findByTestId('caseDescription')).toBeInTheDocument(); + expect(await screen.findByTestId('caseSyncAlerts')).toBeInTheDocument(); + expect(await screen.findByTestId('caseConnectors')).toBeInTheDocument(); + expect(await screen.findByTestId('categories-list')).toBeInTheDocument(); + expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); }); it('does not render solution picker when only one owner is available', async () => { useAvailableOwnersMock.mockReturnValue(['securitySolution']); - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy(); + expect(screen.queryByTestId('caseOwnerSelector')).not.toBeInTheDocument(); }); - it('hides the sync alerts toggle', () => { - const { queryByText } = render( - <MockHookWrapperComponent testProviderProps={{ features: { alerts: { sync: false } } }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + it('hides the sync alerts toggle', async () => { + appMockRenderer = createAppMockRenderer({ features: { alerts: { sync: false } } }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(queryByText('Sync alert')).not.toBeInTheDocument(); - }); - - it('should render spinner when loading', async () => { - const wrapper = mount( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); - - await act(async () => { - globalForm.setFieldValue('title', 'title'); - globalForm.setFieldValue('description', 'description'); - await wrapper.find(`button[data-test-subj="create-case-submit"]`).simulate('click'); - wrapper.update(); - }); - - expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); + expect(screen.queryByText('Sync alert')).not.toBeInTheDocument(); }); it('should not render the assignees on basic license', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); - - expect(result.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + expect(screen.queryByTestId('createCaseAssigneesComboBox')).not.toBeInTheDocument(); }); - it('should render the assignees on platinum license', () => { + it('should render the assignees on platinum license', async () => { const license = licensingMock.createLicense({ license: { type: 'platinum' }, }); - const result = render( - <MockHookWrapperComponent testProviderProps={{ license }}> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + expect(await screen.findByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); - it('should not prefill the form when no initialValue provided', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> + it('should not prefill the form when no initialValue provided', async () => { + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); expect(titleInput).toHaveValue(''); expect(descriptionInput).toHaveValue(''); }); - it('should render custom fields when available', () => { + it('should render custom fields when available', async () => { useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, data: [ @@ -231,70 +159,62 @@ describe('CreateCaseForm', () => { ], })); - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm {...casesFormProps} /> - </MockHookWrapperComponent> - ); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); - expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); for (const item of customFieldsConfigurationMock) { expect( - result.getByTestId(`${item.key}-${item.type}-create-custom-field`) + await screen.findByTestId(`${item.key}-${item.type}-create-custom-field`) ).toBeInTheDocument(); } }); - it('should prefill the form when provided with initialValue', () => { - const { getByTestId } = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should prefill the form when provided with initialValue', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); - const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + const titleInput = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByRole( + 'textbox' + ); expect(titleInput).toHaveValue('title'); expect(descriptionInput).toHaveValue('description'); }); describe('draft comment ', () => { - it('should clear session storage key on cancel', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on cancel', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const cancelBtn = result.getByTestId('create-case-cancel'); + const cancelBtn = await screen.findByTestId('create-case-cancel'); fireEvent.click(cancelBtn); - fireEvent.click(result.getByTestId('confirmModalConfirmButton')); + fireEvent.click(await screen.findByTestId('confirmModalConfirmButton')); expect(casesFormProps.onCancel).toHaveBeenCalled(); expect(sessionStorage.getItem(draftStorageKey)).toBe(null); }); - it('should clear session storage key on submit', () => { - const result = render( - <MockHookWrapperComponent> - <CreateCaseForm - {...casesFormProps} - initialValue={{ title: 'title', description: 'description' }} - /> - </MockHookWrapperComponent> + it('should clear session storage key on submit', async () => { + appMockRenderer.render( + <CreateCaseForm + {...casesFormProps} + initialValue={{ title: 'title', description: 'description' }} + /> ); - const submitBtn = result.getByTestId('create-case-submit'); + const submitBtn = await screen.findByTestId('create-case-submit'); fireEvent.click(submitBtn); @@ -304,4 +224,115 @@ describe('CreateCaseForm', () => { }); }); }); + + describe('templates', () => { + beforeEach(() => { + useGetAllCaseConfigurationsMock.mockReturnValue({ + ...useGetAllCaseConfigurationsResponse, + data: [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + required: false, + label: 'My test label 1', + }, + ], + templates: templatesConfigurationMock, + }, + ], + }); + }); + + it('should populate the cases fields correctly when selecting a case template', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const selectedTemplate = templatesConfigurationMock[4]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(selectedTemplate.caseFields?.title); + expect(description).toHaveValue(selectedTemplate.caseFields?.description); + expect(tags).toHaveTextContent(selectedTemplate.caseFields?.tags?.[0]!); + expect(category).toHaveValue(selectedTemplate.caseFields?.category); + expect(severity).toHaveTextContent('High'); + expect(customField).toHaveValue('this is a text field value'); + expect(await screen.findByText('Damaged Raccoon')).toBeInTheDocument(); + + expect(await screen.findByText('Jira')).toBeInTheDocument(); + expect(await screen.findByTestId('connector-fields-jira')).toBeInTheDocument(); + }); + + it('changes templates correctly', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + const firstTemplate = templatesConfigurationMock[4]; + const secondTemplate = templatesConfigurationMock[2]; + + appMockRenderer = createAppMockRenderer({ license }); + appMockRenderer.render(<CreateCaseForm {...casesFormProps} />); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + firstTemplate.name + ); + + const title = within(await screen.findByTestId('caseTitle')).getByTestId('input'); + const description = within(await screen.findByTestId('caseDescription')).getByRole('textbox'); + const tags = within(await screen.findByTestId('caseTags')).getByTestId('comboBoxInput'); + const category = within(await screen.findByTestId('caseCategory')).getByTestId( + 'comboBoxSearchInput' + ); + const assignees = within(await screen.findByTestId('caseAssignees')).getByTestId( + 'comboBoxSearchInput' + ); + const severity = await screen.findByTestId('case-severity-selection'); + const customField = await screen.findByTestId( + 'first_custom_field_key-text-create-custom-field' + ); + + expect(title).toHaveValue(firstTemplate.caseFields?.title); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + secondTemplate.name + ); + + expect(title).toHaveValue(secondTemplate.caseFields?.title); + expect(description).not.toHaveValue(); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[0]!); + expect(tags).toHaveTextContent(secondTemplate.caseFields?.tags?.[1]!); + expect(category).not.toHaveValue(); + expect(severity).toHaveTextContent('Medium'); + expect(customField).not.toHaveValue(); + expect(assignees).not.toHaveValue(); + + expect(screen.queryByText('Damaged Raccoon')).not.toBeInTheDocument(); + expect(screen.queryByText('Jira')).not.toBeInTheDocument(); + expect(screen.queryByTestId('connector-fields-jira')).not.toBeInTheDocument(); + + expect(await screen.findByText('No connector selected')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 8e860527ec658..db6df19308e51 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -5,30 +5,13 @@ * 2.0. */ -import React, { useMemo } from 'react'; -import type { EuiThemeComputed } from '@elastic/eui'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiSteps, - useEuiTheme, - logicalCSS, -} from '@elastic/eui'; -import { css } from '@emotion/react'; - +import React, { useCallback, useState, useMemo } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; - -import type { ActionConnector } from '../../../common/types/domain'; import type { CasePostRequest } from '../../../common/types/api'; -import { Title } from './title'; -import { Description, fieldName as descriptionFieldName } from './description'; -import { Tags } from './tags'; -import { Connector } from './connector'; +import { fieldName as descriptionFieldName } from '../case_form_fields/description'; import * as i18n from './translations'; -import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { CaseUI } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import type { CasesTimelineIntegration } from '../timeline_context'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { InsertTimeline } from '../insert_timeline'; @@ -37,33 +20,19 @@ import type { UseCreateAttachments } from '../../containers/use_create_attachmen import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { SubmitCaseButton } from './submit_button'; import { FormContext } from './form_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; -import { Severity } from './severity'; -import { Assignees } from './assignees'; import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; -import { Category } from './category'; -import { CustomFields } from './custom_fields'; - -const containerCss = (euiTheme: EuiThemeComputed<{}>, big?: boolean) => - big - ? css` - ${logicalCSS('margin-top', euiTheme.size.xl)}; - ` - : css` - ${logicalCSS('margin-top', euiTheme.size.base)}; - `; +import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { getConfigurationByOwner } from '../../containers/configure/utils'; +import { CreateCaseOwnerSelector } from './owner_selector'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { getInitialCaseValue, getOwnerDefaultValue } from './utils'; -export interface CreateCaseFormFieldsProps { - connectors: ActionConnector[]; - isLoadingConnectors: boolean; - withSteps: boolean; - draftStorageKey: string; -} export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsProps>, 'withSteps'> { onCancel: () => void; onSuccess: (theCase: CaseUI) => void; @@ -76,130 +45,70 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr initialValue?: Pick<CasePostRequest, 'title' | 'description'>; } -const empty: ActionConnector[] = []; -export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( - ({ connectors, isLoadingConnectors, withSteps, draftStorageKey }) => { +type FormFieldsWithFormContextProps = Pick< + CreateCaseFormFieldsProps, + 'withSteps' | 'draftStorageKey' +> & { + isLoadingCaseConfiguration: boolean; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; + onSelectedOwner: (owner: string) => void; +}; + +export const FormFieldsWithFormContext: React.FC<FormFieldsWithFormContextProps> = React.memo( + ({ + currentConfiguration, + isLoadingCaseConfiguration, + withSteps, + draftStorageKey, + selectedOwner, + onSelectedOwner, + }) => { const { owner } = useCasesContext(); - const { isSubmitting } = useFormContext(); - const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); - const { euiTheme } = useEuiTheme(); const availableOwners = useAvailableCasesOwners(); - const canShowCaseSolutionSelection = !owner.length && availableOwners.length > 1; - - const firstStep = useMemo( - () => ({ - title: i18n.STEP_ONE_TITLE, - children: ( - <> - <Title isLoading={isSubmitting} autoFocus={true} /> - {caseAssignmentAuthorized ? ( - <div css={containerCss(euiTheme)}> - <Assignees isLoading={isSubmitting} /> - </div> - ) : null} - <div css={containerCss(euiTheme)}> - <Tags isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Category isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)}> - <Severity isLoading={isSubmitting} /> - </div> - {canShowCaseSolutionSelection && ( - <div css={containerCss(euiTheme, true)}> - <CreateCaseOwnerSelector - availableOwners={availableOwners} - isLoading={isSubmitting} - /> - </div> - )} - <div css={containerCss(euiTheme, true)}> - <Description isLoading={isSubmitting} draftStorageKey={draftStorageKey} /> - </div> - <div css={containerCss(euiTheme)}> - <CustomFields isLoading={isSubmitting} /> - </div> - <div css={containerCss(euiTheme)} /> - </> - ), - }), - [ - isSubmitting, - euiTheme, - caseAssignmentAuthorized, - canShowCaseSolutionSelection, - availableOwners, - draftStorageKey, - ] - ); - - const secondStep = useMemo( - () => ({ - title: i18n.STEP_TWO_TITLE, - children: ( - <div> - <SyncAlertsToggle isLoading={isSubmitting} /> - </div> - ), - }), - [isSubmitting] - ); - - const thirdStep = useMemo( - () => ({ - title: i18n.STEP_THREE_TITLE, - children: ( - <div> - <Connector - connectors={connectors} - isLoadingConnectors={isLoadingConnectors} - isLoading={isSubmitting} - /> - </div> - ), - }), - [connectors, isLoadingConnectors, isSubmitting] - ); - - const allSteps = useMemo( - () => [firstStep, ...(isSyncAlertsEnabled ? [secondStep] : []), thirdStep], - [isSyncAlertsEnabled, firstStep, secondStep, thirdStep] + const shouldShowOwnerSelector = Boolean(!owner.length && availableOwners.length > 1); + const { reset } = useFormContext(); + + const { data: connectors = [], isLoading: isLoadingConnectors } = + useGetSupportedActionConnectors(); + + const onOwnerChange = useCallback( + (newOwner: string) => { + onSelectedOwner(newOwner); + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ + owner: newOwner, + connector: currentConfiguration.connector, + }), + }); + }, + [currentConfiguration.connector, onSelectedOwner, reset] ); return ( <> - {isSubmitting && ( - <EuiLoadingSpinner - css={css` - position: absolute; - top: 50%; - left: 50%; - z-index: 99; - `} - data-test-subj="create-case-loading-spinner" - size="xl" - /> - )} - {withSteps ? ( - <EuiSteps - headingElement="h2" - steps={allSteps} - data-test-subj={'case-creation-form-steps'} + {shouldShowOwnerSelector && ( + <CreateCaseOwnerSelector + selectedOwner={selectedOwner} + availableOwners={availableOwners} + isLoading={isLoadingCaseConfiguration} + onOwnerChange={onOwnerChange} /> - ) : ( - <> - {firstStep.children} - {isSyncAlertsEnabled && secondStep.children} - {thirdStep.children} - </> )} + <CreateCaseFormFields + connectors={connectors} + isLoading={isLoadingConnectors || isLoadingCaseConfiguration} + withSteps={withSteps} + draftStorageKey={draftStorageKey} + configuration={currentConfiguration} + /> </> ); } ); -CreateCaseFormFields.displayName = 'CreateCaseFormFields'; +FormFieldsWithFormContext.displayName = 'FormFieldsWithFormContext'; export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( ({ @@ -212,6 +121,13 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( initialValue, }) => { const { owner } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(); + const defaultOwnerValue = owner[0] ?? getOwnerDefaultValue(availableOwners); + const [selectedOwner, onSelectedOwner] = useState<string>(defaultOwnerValue); + + const { data: configurations, isLoading: isLoadingCaseConfiguration } = + useGetAllCaseConfigurations(); + const draftStorageKey = getMarkdownEditorStorageKey({ appId: owner[0], caseId: 'createCase', @@ -233,6 +149,15 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( return onSuccess(theCase); }; + const currentConfiguration = useMemo( + () => + getConfigurationByOwner({ + configurations, + owner: selectedOwner, + }), + [configurations, selectedOwner] + ); + return ( <CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}> <FormContext @@ -240,14 +165,18 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( onSuccess={handleOnSuccess} attachments={attachments} initialValue={initialValue} + currentConfiguration={currentConfiguration} + selectedOwner={selectedOwner} > - <CreateCaseFormFields - connectors={empty} - isLoadingConnectors={false} + <FormFieldsWithFormContext withSteps={withSteps} draftStorageKey={draftStorageKey} + selectedOwner={selectedOwner} + onSelectedOwner={onSelectedOwner} + isLoadingCaseConfiguration={isLoadingCaseConfiguration} + currentConfiguration={currentConfiguration} /> - <div> + <EuiFormRow fullWidth> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" @@ -275,7 +204,7 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo( <SubmitCaseButton /> </EuiFlexItem> </EuiFlexGroup> - </div> + </EuiFormRow> <InsertTimeline fieldName={descriptionFieldName} /> </FormContext> </CasesTimelineIntegrationProvider> diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 4c8991f0cb590..5417807edf168 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -16,7 +16,6 @@ import { createAppMockRenderer } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; @@ -39,8 +38,6 @@ import { useGetChoicesResponse, } from './mock'; import { FormContext } from './form_context'; -import type { CreateCaseFormFieldsProps } from './form'; -import { CreateCaseFormFields } from './form'; import { SubmitCaseButton } from './submit_button'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import userEvent from '@testing-library/user-event'; @@ -60,13 +57,15 @@ import { CustomFieldTypes, } from '../../../common/types/domain'; import { useAvailableCasesOwners } from '../app/use_available_owners'; +import type { CreateCaseFormFieldsProps } from './form_fields'; +import { CreateCaseFormFields } from './form_fields'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/use_post_case'); jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('../../containers/configure/use_get_all_case_configurations'); jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); @@ -81,7 +80,6 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../app/use_available_owners'); const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useGetAllCaseConfigurationsMock = useGetAllCaseConfigurations as jest.Mock; const usePostCaseMock = usePostCase as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; @@ -106,8 +104,11 @@ const defaultPostCase = { mutateAsync: postCase, }; +const currentConfiguration = useGetAllCaseConfigurationsResponse.data[0]; + const defaultCreateCaseForm: CreateCaseFormFieldsProps = { - isLoadingConnectors: false, + configuration: currentConfiguration, + isLoading: false, connectors: [], withSteps: true, draftStorageKey: 'cases.kibana.createCase.description.markdownEditor', @@ -205,7 +206,6 @@ describe('Create case', () => { useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetAllCaseConfigurationsMock.mockImplementation(() => useGetAllCaseConfigurationsResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); @@ -244,7 +244,11 @@ describe('Create case', () => { describe('Step 1 - Case Fields', () => { it('renders correctly', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -269,7 +273,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -294,7 +302,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -328,7 +340,11 @@ describe('Create case', () => { const newCategory = 'First '; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -373,7 +389,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -408,7 +428,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -431,7 +455,11 @@ describe('Create case', () => { it('should select LOW as the default severity', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -446,27 +474,28 @@ describe('Create case', () => { }); it('should submit form with custom fields', async () => { - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - customFields: [ - ...customFieldsConfigurationMock, - { - key: 'my_custom_field_key', - type: CustomFieldTypes.TEXT, - label: 'my custom field label', - required: false, - }, - ], - }, - ], - })); + const configurations = [ + { + ...useGetAllCaseConfigurationsResponse.data[0], + customFields: [ + ...customFieldsConfigurationMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'my custom field label', + required: false, + }, + ], + }, + ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={configurations[0]} + > + <CreateCaseFormFields {...defaultCreateCaseForm} configuration={configurations[0]} /> <SubmitCaseButton /> </FormContext> ); @@ -477,7 +506,7 @@ describe('Create case', () => { const textField = customFieldsConfigurationMock[0]; const toggleField = customFieldsConfigurationMock[1]; - expect(await screen.findByTestId('create-case-custom-fields')).toBeInTheDocument(); + expect(await screen.findByTestId('caseCustomFields')).toBeInTheDocument(); const textCustomField = await screen.findByTestId( `${textField.key}-${textField.type}-create-custom-field` @@ -512,147 +541,20 @@ describe('Create case', () => { }); }); - it('should change custom fields based on the selected owner', async () => { - appMockRender = createAppMockRenderer({ owner: [] }); - - const securityCustomField = { - key: 'security_custom_field', - type: CustomFieldTypes.TEXT, - label: 'security custom field', - required: false, - }; - const o11yCustomField = { - key: 'o11y_field_key', - type: CustomFieldTypes.TEXT, - label: 'observability custom field', - required: false, - }; - const stackCustomField = { - key: 'stack_field_key', - type: CustomFieldTypes.TEXT, - label: 'stack custom field', - required: false, - }; - - useGetAllCaseConfigurationsMock.mockImplementation(() => ({ - ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'securitySolution', - customFields: [securityCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'observability', - customFields: [o11yCustomField], - }, - { - ...useGetAllCaseConfigurationsResponse.data[0], - owner: 'cases', - customFields: [stackCustomField], - }, - ], - })); - - appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - ); - - await waitForFormToRender(screen); - await fillFormReactTestingLib({ renderer: screen }); - - const createCaseCustomFields = await screen.findByTestId('create-case-custom-fields'); - - // the default selectedOwner is securitySolution - // only the security custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - const caseOwnerSelector = await screen.findByTestId('caseOwnerSelector'); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Observability')); - - // only the o11y custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - - userEvent.click(await within(caseOwnerSelector).findByLabelText('Stack')); - - // only the stack custom field should be displayed - expect( - await within(createCaseCustomFields).findByTestId( - `${stackCustomField.key}-${stackCustomField.type}-create-custom-field` - ) - ).toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${securityCustomField.key}-${securityCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - expect( - await within(createCaseCustomFields).queryByTestId( - `${o11yCustomField.key}-${o11yCustomField.type}-create-custom-field` - ) - ).not.toBeInTheDocument(); - }); - it('should select the default connector set in the configuration', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -661,8 +563,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -694,32 +604,19 @@ describe('Create case', () => { }); it('should default to none if the default connector does not exist in connectors', async () => { - useGetCaseConfigurationMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - data: { - ...useCaseConfigureResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, + const configuration = { + ...useCaseConfigureResponse.data, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, }, - })); + }; useGetAllCaseConfigurationsMock.mockImplementation(() => ({ ...useGetAllCaseConfigurationsResponse, - data: [ - { - ...useGetAllCaseConfigurationsResponse.data, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - }, - ], + data: [configuration], })); useGetConnectorsMock.mockReturnValue({ @@ -728,8 +625,16 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields + {...defaultCreateCaseForm} + configuration={configuration} + connectors={connectorsMock} + /> <SubmitCaseButton /> </FormContext> ); @@ -757,7 +662,11 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -788,8 +697,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -861,8 +774,12 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectors} /> <SubmitCaseButton /> </FormContext> ); @@ -914,8 +831,13 @@ describe('Create case', () => { }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + afterCaseCreated={afterCaseCreated} + currentConfiguration={currentConfiguration} + > + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -977,7 +899,12 @@ describe('Create case', () => { ]; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1008,7 +935,12 @@ describe('Create case', () => { const attachments: CaseAttachments = []; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + attachments={attachments} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1044,11 +976,13 @@ describe('Create case', () => { appMockRender.render( <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + currentConfiguration={currentConfiguration} onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated} attachments={attachments} > - <CreateCaseFormFields {...defaultCreateCaseForm} /> + <CreateCaseFormFields {...defaultCreateCaseForm} connectors={connectorsMock} /> <SubmitCaseButton /> </FormContext> ); @@ -1098,7 +1032,11 @@ describe('Create case', () => { }; appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1129,7 +1067,11 @@ describe('Create case', () => { it('should submit assignees', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1168,7 +1110,11 @@ describe('Create case', () => { useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1193,7 +1139,11 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> @@ -1221,14 +1171,18 @@ describe('Create case', () => { it('should have session storage value same as draft comment', async () => { appMockRender.render( - <FormContext onSuccess={onFormSubmitSuccess}> + <FormContext + selectedOwner={SECURITY_SOLUTION_OWNER} + onSuccess={onFormSubmitSuccess} + currentConfiguration={currentConfiguration} + > <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); await waitForFormToRender(screen); - const descriptionInput = within(screen.getByTestId('caseDescription')).getByTestId( + const descriptionInput = within(await screen.findByTestId('caseDescription')).getByTestId( 'euiMarkdownEditorTextArea' ); diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 04a327868418f..54198f8510e5e 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -5,46 +5,22 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import { CaseSeverity } from '../../../common/types/domain'; -import type { FormProps } from './schema'; import { schema } from './schema'; -import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import type { CasesConfigurationUI, CaseUI, CaseUICustomField } from '../../containers/types'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import type { CasePostRequest } from '../../../common/types/api'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { - getConnectorById, - getConnectorsFormDeserializer, - getConnectorsFormSerializer, - convertCustomFieldValue, -} from '../utils'; -import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions'; -import { useGetAllCaseConfigurations } from '../../containers/configure/use_get_all_case_configurations'; import { useApplication } from '../../common/lib/kibana/use_application'; - -const initialCaseValue: FormProps = { - description: '', - tags: [], - title: '', - severity: CaseSeverity.LOW, - connectorId: NONE_CONNECTOR_ID, - fields: null, - syncAlerts: true, - assignees: [], - customFields: {}, -}; +import { createFormSerializer, createFormDeserializer, getInitialCaseValue } from './utils'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; interface Props { afterCaseCreated?: ( @@ -55,6 +31,8 @@ interface Props { onSuccess?: (theCase: CaseUI) => void; attachments?: CaseAttachmentsWithoutOwner; initialValue?: Pick<CasePostRequest, 'title' | 'description'>; + currentConfiguration: CasesConfigurationUI; + selectedOwner: string; } export const FormContext: React.FC<Props> = ({ @@ -63,111 +41,23 @@ export const FormContext: React.FC<Props> = ({ onSuccess, attachments, initialValue, + currentConfiguration, + selectedOwner, }) => { - const { data: connectors = [], isLoading: isLoadingConnectors } = - useGetSupportedActionConnectors(); - const { data: allConfigurations } = useGetAllCaseConfigurations(); - const { owner } = useCasesContext(); const { appId } = useApplication(); - const { isSyncAlertsEnabled } = useCasesFeatures(); + const { data: connectors = [] } = useGetSupportedActionConnectors(); const { mutateAsync: postCase } = usePostCase(); const { mutateAsync: createAttachments } = useCreateAttachments(); const { mutateAsync: pushCaseToExternalService } = usePostPushToService(); const { startTransaction } = useCreateCaseWithAttachmentsTransaction(); - const availableOwners = useAvailableCasesOwners(); - - const trimUserFormData = (userFormData: CaseUI) => { - let formData = { - ...userFormData, - title: userFormData.title.trim(), - description: userFormData.description.trim(), - }; - - if (userFormData.category) { - formData = { ...formData, category: userFormData.category.trim() }; - } - - if (userFormData.tags) { - formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; - } - - return formData; - }; - - const transformCustomFieldsData = useCallback( - ( - customFields: Record<string, string | boolean>, - selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] - ) => { - const transformedCustomFields: CaseUI['customFields'] = []; - - if (!customFields || !selectedCustomFieldsConfiguration.length) { - return []; - } - - for (const [key, value] of Object.entries(customFields)) { - const configCustomField = selectedCustomFieldsConfiguration.find( - (item) => item.key === key - ); - if (configCustomField) { - transformedCustomFields.push({ - key: configCustomField.key, - type: configCustomField.type, - value: convertCustomFieldValue(value), - } as CaseUICustomField); - } - } - - return transformedCustomFields; - }, - [] - ); const submitCase = useCallback( - async ( - { - connectorId: dataConnectorId, - fields, - syncAlerts = isSyncAlertsEnabled, - ...dataWithoutConnectorId - }, - isValid - ) => { + async (data: CasePostRequest, isValid) => { if (isValid) { - const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId; - const caseConnector = getConnectorById(dataConnectorId, connectors); - const defaultOwner = owner[0] ?? availableOwners[0]; - startTransaction({ appId, attachments }); - const connectorToUpdate = caseConnector - ? normalizeActionConnector(caseConnector, fields) - : getNoneConnector(); - - const configurationOwner: string | undefined = selectedOwner ? selectedOwner : owner[0]; - const selectedConfiguration = allConfigurations.find( - (element: CasesConfigurationUI) => element.owner === configurationOwner - ); - - const customFieldsConfiguration = selectedConfiguration - ? selectedConfiguration.customFields - : []; - - const transformedCustomFields = transformCustomFieldsData( - customFields, - customFieldsConfiguration ?? [] - ); - - const trimmedData = trimUserFormData(userFormData); - const theCase = await postCase({ - request: { - ...trimmedData, - connector: connectorToUpdate, - settings: { syncAlerts }, - owner: selectedOwner ?? defaultOwner, - customFields: transformedCustomFields, - }, + request: data, }); // add attachments to the case @@ -183,10 +73,10 @@ export const FormContext: React.FC<Props> = ({ await afterCaseCreated(theCase, createAttachments); } - if (theCase?.id && connectorToUpdate.id !== 'none') { + if (theCase?.id && data.connector.id !== 'none') { await pushCaseToExternalService({ caseId: theCase.id, - connector: connectorToUpdate, + connector: data.connector, }); } @@ -196,15 +86,9 @@ export const FormContext: React.FC<Props> = ({ } }, [ - isSyncAlertsEnabled, - connectors, - owner, - availableOwners, startTransaction, appId, attachments, - transformCustomFieldsData, - allConfigurations, postCase, afterCaseCreated, onSuccess, @@ -213,27 +97,34 @@ export const FormContext: React.FC<Props> = ({ ] ); - const { form } = useForm<FormProps>({ - defaultValue: { ...initialCaseValue, ...initialValue }, + const { form } = useForm({ + defaultValue: { + /** + * This is needed to initiate the connector + * with the one set in the configuration + * when creating a case. + */ + ...getInitialCaseValue({ + owner: selectedOwner, + connector: currentConfiguration.connector, + }), + ...initialValue, + }, options: { stripEmptyFields: false }, schema, onSubmit: submitCase, - serializer: getConnectorsFormSerializer, - deserializer: getConnectorsFormDeserializer, + serializer: (data: CaseFormFieldsSchemaProps) => + createFormSerializer( + connectors, + { + ...currentConfiguration, + owner: selectedOwner, + }, + data + ), + deserializer: createFormDeserializer, }); - const childrenWithExtraProp = useMemo( - () => - children != null - ? React.Children.map(children, (child: React.ReactElement) => - React.cloneElement(child, { - connectors, - isLoadingConnectors, - }) - ) - : null, - [children, connectors, isLoadingConnectors] - ); return ( <Form onKeyDown={(e: KeyboardEvent) => { @@ -245,7 +136,7 @@ export const FormContext: React.FC<Props> = ({ }} form={form} > - {childrenWithExtraProp} + {children} </Form> ); }; diff --git a/x-pack/plugins/cases/public/components/create/form_fields.tsx b/x-pack/plugins/cases/public/components/create/form_fields.tsx new file mode 100644 index 0000000000000..26189e33b7f12 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/form_fields.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useEffect } from 'react'; +import { + EuiLoadingSpinner, + EuiSteps, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +import type { CasePostRequest } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { Connector } from '../case_form_fields/connector'; +import * as i18n from './translations'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { removeEmptyFields } from '../utils'; +import { useCasesFeatures } from '../../common/use_cases_features'; +import { TemplateSelector } from './templates'; +import { getInitialCaseValue } from './utils'; +import { CaseFormFields } from '../case_form_fields'; + +export interface CreateCaseFormFieldsProps { + configuration: CasesConfigurationUI; + connectors: ActionConnector[]; + isLoading: boolean; + withSteps: boolean; + draftStorageKey: string; +} + +const transformTemplateCaseFieldsToCaseFormFields = ( + owner: string, + caseTemplateFields: CasesConfigurationUITemplate['caseFields'] +): CasePostRequest => { + const caseFields = removeEmptyFields(caseTemplateFields ?? {}); + return getInitialCaseValue({ owner, ...caseFields }); +}; + +export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.memo( + ({ configuration, connectors, isLoading, withSteps, draftStorageKey }) => { + const { reset, updateFieldValues, isSubmitting, setFieldValue } = useFormContext(); + const { isSyncAlertsEnabled } = useCasesFeatures(); + const configurationOwner = configuration.owner; + + /** + * Changes the selected connector + * when the user selects a solution. + * Each solution has its own configuration + * so the connector has to change. + */ + useEffect(() => { + setFieldValue('connectorId', configuration.connector.id); + }, [configuration.connector.id, setFieldValue]); + + const onTemplateChange = useCallback( + (caseFields: CasesConfigurationUITemplate['caseFields']) => { + const caseFormFields = transformTemplateCaseFieldsToCaseFormFields( + configurationOwner, + caseFields + ); + + reset({ + resetValues: true, + defaultValue: getInitialCaseValue({ owner: configurationOwner }), + }); + updateFieldValues(caseFormFields); + }, + [configurationOwner, reset, updateFieldValues] + ); + + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <TemplateSelector + isLoading={isSubmitting || isLoading} + templates={configuration.templates} + onTemplateChange={onTemplateChange} + /> + ), + }), + [configuration.templates, isLoading, isSubmitting, onTemplateChange] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( + <CaseFormFields + configurationCustomFields={configuration.customFields} + isLoading={isSubmitting} + setCustomFieldsOptional={false} + isEditMode={false} + draftStorageKey={draftStorageKey} + /> + ), + }), + [configuration.customFields, draftStorageKey, isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, + children: <SyncAlertsToggle isLoading={isSubmitting} />, + }), + [isSubmitting] + ); + + const fourthStep = useMemo( + () => ({ + title: i18n.STEP_FOUR_TITLE, + children: ( + <Connector + connectors={connectors} + isLoadingConnectors={isLoading} + isLoading={isSubmitting} + key={configuration.id} + /> + ), + }), + [configuration.id, connectors, isLoading, isSubmitting] + ); + + const allSteps = useMemo( + () => [firstStep, secondStep, ...(isSyncAlertsEnabled ? [thirdStep] : []), fourthStep], + [firstStep, secondStep, isSyncAlertsEnabled, thirdStep, fourthStep] + ); + + return ( + <> + {isSubmitting && ( + <EuiLoadingSpinner + css={css` + position: absolute; + top: 50%; + left: 50%; + z-index: 99; + `} + data-test-subj="create-case-loading-spinner" + size="xl" + /> + )} + {withSteps ? ( + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'case-creation-form-steps'} + /> + ) : ( + <> + <EuiSpacer size="l" /> + <EuiFlexGroup direction="column"> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_ONE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{firstStep.children}</EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_TWO_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{secondStep.children}</EuiFlexItem> + </EuiFlexGroup> + {isSyncAlertsEnabled && ( + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_THREE_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{thirdStep.children}</EuiFlexItem> + </EuiFlexGroup> + )} + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTitle size="s"> + <h2>{i18n.STEP_FOUR_TITLE}</h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem>{fourthStep.children}</EuiFlexItem> + </EuiFlexGroup> + </EuiFlexGroup> + </> + )} + </> + ); + } +); + +CreateCaseFormFields.displayName = 'CreateCaseFormFields'; diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx index 451207b080dfb..c61dd83dea42f 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.test.tsx @@ -11,13 +11,14 @@ import { waitFor, screen } from '@testing-library/react'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { OBSERVABILITY_OWNER, OWNER_INFO } from '../../../common/constants'; import { CreateCaseOwnerSelector } from './owner_selector'; -import { FormTestComponent } from '../../common/test_utils'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import userEvent from '@testing-library/user-event'; describe('Case Owner Selection', () => { - const onSubmit = jest.fn(); + const onOwnerChange = jest.fn(); + const selectedOwner = SECURITY_SOLUTION_OWNER; + let appMockRender: AppMockRenderer; beforeEach(() => { @@ -25,92 +26,66 @@ describe('Case Owner Selection', () => { appMockRender = createAppMockRenderer(); }); - it('renders', async () => { + it('renders all options', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[SECURITY_SOLUTION_OWNER]} isLoading={false} /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); expect(await screen.findByTestId('caseOwnerSelector')).toBeInTheDocument(); - }); - it.each([ - [OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER], - [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER], - ])('disables %s button if user only has %j', async (disabledButton, permission) => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[permission]} isLoading={false} /> - </FormTestComponent> - ); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - expect(await screen.findByLabelText(OWNER_INFO[disabledButton].label)).toBeDisabled(); - expect(await screen.findByLabelText(OWNER_INFO[permission].label)).not.toBeDisabled(); + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveTextContent(OWNER_INFO[SECURITY_SOLUTION_OWNER].label); + expect(options[1]).toHaveTextContent(OWNER_INFO[OBSERVABILITY_OWNER].label); }); - it('defaults to security Solution', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> + it.each([[SECURITY_SOLUTION_OWNER], [OBSERVABILITY_OWNER]])( + 'only displays %s option if available', + async (available) => { + appMockRender.render( <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + availableOwners={[available]} isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={available} /> - </FormTestComponent> - ); - - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + ); - it('defaults to security Solution with empty owners', async () => { - appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector availableOwners={[]} isLoading={false} /> - </FormTestComponent> - ); + expect(await screen.findByText(OWNER_INFO[available].label)).toBeInTheDocument(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); - expect(await screen.findByLabelText('Security')).toBeChecked(); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); - - await waitFor(() => { - // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'securitySolution' }, true); - }); - }); + expect((await screen.findAllByRole('option')).length).toBe(1); + } + ); it('changes the selection', async () => { appMockRender.render( - <FormTestComponent onSubmit={onSubmit}> - <CreateCaseOwnerSelector - availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} - isLoading={false} - /> - </FormTestComponent> + <CreateCaseOwnerSelector + availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]} + isLoading={false} + onOwnerChange={onOwnerChange} + selectedOwner={selectedOwner} + /> ); - expect(await screen.findByLabelText('Security')).toBeChecked(); - expect(await screen.findByLabelText('Observability')).not.toBeChecked(); + expect(await screen.findByText('Security')).toBeInTheDocument(); + expect(screen.queryByText('Observability')).not.toBeInTheDocument(); - userEvent.click(await screen.findByLabelText('Observability')); - - expect(await screen.findByLabelText('Observability')).toBeChecked(); - expect(await screen.findByLabelText('Security')).not.toBeChecked(); - - userEvent.click(await screen.findByTestId('form-test-component-submit-button')); + userEvent.click(await screen.findByTestId('caseOwnerSuperSelect')); + userEvent.click(await screen.findByText('Observability'), undefined, { + skipPointerEventsCheck: true, + }); await waitFor(() => { // data, isValid - expect(onSubmit).toBeCalledWith({ selectedOwner: 'observability' }, true); + expect(onOwnerChange).toBeCalledWith('observability'); }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/owner_selector.tsx b/x-pack/plugins/cases/public/components/create/owner_selector.tsx index 00dd4a03f2664..314bbaefc95c8 100644 --- a/x-pack/plugins/cases/public/components/create/owner_selector.tsx +++ b/x-pack/plugins/cases/public/components/create/owner_selector.tsx @@ -5,113 +5,72 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiKeyPadMenu, - EuiKeyPadMenuItem, - useGeneratedHtmlId, -} from '@elastic/eui'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { - getFieldValidityAndErrorMessage, - UseField, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import { OWNER_INFO } from '../../../common/constants'; import * as i18n from './translations'; -interface OwnerSelectorProps { - field: FieldHook<string>; - isLoading: boolean; - availableOwners: string[]; -} - interface Props { + selectedOwner: string; availableOwners: string[]; isLoading: boolean; + onOwnerChange: (owner: string) => void; } -const DEFAULT_SELECTABLE_OWNERS = Object.keys(OWNER_INFO) as Array<keyof typeof OWNER_INFO>; - -const FIELD_NAME = 'selectedOwner'; - -const FullWidthKeyPadMenu = euiStyled(EuiKeyPadMenu)` - width: 100%; -`; - -const FullWidthKeyPadItem = euiStyled(EuiKeyPadMenuItem)` - - width: 100%; -`; - -const OwnerSelector = ({ +const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, - field, - isLoading = false, -}: OwnerSelectorProps): JSX.Element => { - const { errorMessage, isInvalid } = getFieldValidityAndErrorMessage(field); - const radioGroupName = useGeneratedHtmlId({ prefix: 'caseOwnerRadioGroup' }); - - const onChange = useCallback((val: string) => field.setValue(val), [field]); + isLoading, + onOwnerChange, + selectedOwner, +}) => { + const onChange = (owner: string) => { + onOwnerChange(owner); + }; + + const options = Object.entries(OWNER_INFO) + .filter(([owner]) => availableOwners.includes(owner)) + .map(([owner, definition]) => ({ + value: owner, + inputDisplay: ( + <EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <EuiIcon + type={definition.iconType} + size="m" + title={definition.label} + className="eui-alignMiddle" + /> + </EuiFlexItem> + <EuiFlexItem> + <small>{definition.label}</small> + </EuiFlexItem> + </EuiFlexGroup> + ), + 'data-test-subj': `${definition.id}OwnerOption`, + })); return ( <EuiFormRow + display="columnCompressed" + label={i18n.SOLUTION_SELECTOR_LABEL} data-test-subj="caseOwnerSelector" fullWidth - isInvalid={isInvalid} - error={errorMessage} - helpText={field.helpText} - label={field.label} - labelAppend={field.labelAppend} > - <FullWidthKeyPadMenu checkable={{ ariaLegend: i18n.ARIA_KEYPAD_LEGEND }}> - <EuiFlexGroup> - {DEFAULT_SELECTABLE_OWNERS.map((owner) => ( - <EuiFlexItem key={owner}> - <FullWidthKeyPadItem - data-test-subj={`${owner}RadioButton`} - onChange={onChange} - checkable="single" - name={radioGroupName} - id={owner} - label={OWNER_INFO[owner].label} - isSelected={field.value === owner} - isDisabled={isLoading || !availableOwners.includes(owner)} - > - <EuiIcon type={OWNER_INFO[owner].iconType} size="xl" /> - </FullWidthKeyPadItem> - </EuiFlexItem> - ))} - </EuiFlexGroup> - </FullWidthKeyPadMenu> + <EuiSuperSelect + data-test-subj="caseOwnerSuperSelect" + options={options} + isLoading={isLoading} + fullWidth + valueOfSelected={selectedOwner} + onChange={(owner) => onChange(owner)} + compressed + /> </EuiFormRow> ); }; -OwnerSelector.displayName = 'OwnerSelector'; - -const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, isLoading }) => { - const defaultValue = availableOwners.includes(SECURITY_SOLUTION_OWNER) - ? SECURITY_SOLUTION_OWNER - : availableOwners[0] ?? SECURITY_SOLUTION_OWNER; - - return ( - <UseField - path={FIELD_NAME} - config={{ defaultValue }} - component={OwnerSelector} - componentProps={{ availableOwners, isLoading }} - /> - ); -}; - CaseOwnerSelector.displayName = 'CaseOwnerSelectionComponent'; export const CreateCaseOwnerSelector = memo(CaseOwnerSelector); diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 9d07efbf36111..2f92857930d98 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,140 +5,34 @@ * 2.0. */ -import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { FIELD_TYPES, VALIDATION_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { FieldConfig, FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; -import type { ConnectorTypeFields } from '../../../common/types/domain'; -import type { CasePostRequest } from '../../../common/types/api'; -import { - MAX_TITLE_LENGTH, - MAX_DESCRIPTION_LENGTH, - MAX_LENGTH_PER_TAG, - MAX_TAGS_PER_CASE, -} from '../../../common/constants'; import * as i18n from './translations'; -import { OptionalFieldLabel } from './optional_field_label'; -import { SEVERITY_TITLE } from '../severity/translations'; -const { emptyField, maxLengthField } = fieldValidators; +const { emptyField } = fieldValidators; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; -const isInvalidTag = (value: string) => value.trim() === ''; +const caseFormFieldsSchemaTyped = caseFormFieldsSchema as Record<string, FieldConfig<string>>; -const isTagCharactersInLimit = (value: string) => value.trim().length > MAX_LENGTH_PER_TAG; - -export const schemaTags = { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.TAGS, - helpText: i18n.TAGS_HELP, - labelAppend: OptionalFieldLabel, - validations: [ - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isInvalidTag(value)) || - (Array.isArray(value) && value.length > 0 && value.find(isInvalidTag)) - ) { - return { - message: i18n.TAGS_EMPTY_ERROR, - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string | string[] }) => { - if ( - (!Array.isArray(value) && isTagCharactersInLimit(value)) || - (Array.isArray(value) && value.length > 0 && value.some(isTagCharactersInLimit)) - ) { - return { - message: i18n.MAX_LENGTH_ERROR('tag', MAX_LENGTH_PER_TAG), - }; - } - }, - type: VALIDATION_TYPES.ARRAY_ITEM, - isBlocking: false, - }, - { - validator: ({ value }: { value: string[] }) => { - if (Array.isArray(value) && value.length > MAX_TAGS_PER_CASE) { - return { - message: i18n.MAX_TAGS_ERROR(MAX_TAGS_PER_CASE), - }; - } - }, - }, - ], -}; - -export type FormProps = Omit< - CasePostRequest, - 'connector' | 'settings' | 'owner' | 'customFields' -> & { - connectorId: string; - fields: ConnectorTypeFields['fields']; - syncAlerts: boolean; - selectedOwner?: string | null; - customFields: Record<string, string | boolean>; -}; - -export const schema: FormSchema<FormProps> = { +export const schema: FormSchema<CaseFormFieldsSchemaProps> = { + ...caseFormFieldsSchema, title: { - type: FIELD_TYPES.TEXT, - label: i18n.NAME, + ...caseFormFieldsSchemaTyped.title, validations: [ { validator: emptyField(i18n.TITLE_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_TITLE_LENGTH, - message: i18n.MAX_LENGTH_ERROR('name', MAX_TITLE_LENGTH), - }), - }, + ...(caseFormFieldsSchemaTyped.title.validations ?? []), ], }, description: { - label: i18n.DESCRIPTION, + ...caseFormFieldsSchemaTyped.description, validations: [ { validator: emptyField(i18n.DESCRIPTION_REQUIRED), }, - { - validator: maxLengthField({ - length: MAX_DESCRIPTION_LENGTH, - message: i18n.MAX_LENGTH_ERROR('description', MAX_DESCRIPTION_LENGTH), - }), - }, - ], - }, - selectedOwner: { - label: i18n.SOLUTION, - type: FIELD_TYPES.RADIO_GROUP, - validations: [ - { - validator: emptyField(i18n.SOLUTION_REQUIRED), - }, + ...(caseFormFieldsSchemaTyped.description.validations ?? []), ], }, - tags: schemaTags, - severity: { - label: SEVERITY_TITLE, - }, - connectorId: { - type: FIELD_TYPES.SUPER_SELECT, - label: i18n.CONNECTORS, - defaultValue: 'none', - }, - fields: { - defaultValue: null, - }, - syncAlerts: { - helpText: i18n.SYNC_ALERTS_HELP, - type: FIELD_TYPES.TOGGLE, - defaultValue: true, - }, - assignees: {}, - category: {}, }; diff --git a/x-pack/plugins/cases/public/components/create/template.test.tsx b/x-pack/plugins/cases/public/components/create/template.test.tsx new file mode 100644 index 0000000000000..d3b1c59b71254 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/template.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { templatesConfigurationMock } from '../../containers/mock'; +import { TemplateSelector } from './templates'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onTemplateChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + expect(await screen.findByText('Template name')).toBeInTheDocument(); + expect(await screen.findByTestId('create-case-template-select')).toBeInTheDocument(); + }); + + it('selects a template correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + await waitFor(() => { + expect(onTemplateChange).toHaveBeenCalledWith(selectedTemplate.caseFields); + }); + }); + + it('shows the selected option correctly', async () => { + const selectedTemplate = templatesConfigurationMock[2]; + + appMockRender.render( + <TemplateSelector + isLoading={false} + templates={templatesConfigurationMock} + onTemplateChange={onTemplateChange} + /> + ); + + userEvent.selectOptions( + await screen.findByTestId('create-case-template-select'), + selectedTemplate.key + ); + + expect( + (await screen.findByRole<HTMLOptionElement>('option', { name: selectedTemplate.name })) + .selected + ).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/templates.tsx b/x-pack/plugins/cases/public/components/create/templates.tsx new file mode 100644 index 0000000000000..612a7d8a24a70 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/templates.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiSelectOption } from '@elastic/eui'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import type { CasesConfigurationUI, CasesConfigurationUITemplate } from '../../containers/types'; +import { OptionalFieldLabel } from '../optional_field_label'; +import { TEMPLATE_HELP_TEXT, TEMPLATE_LABEL } from './translations'; + +interface Props { + isLoading: boolean; + templates: CasesConfigurationUI['templates']; + onTemplateChange: (caseFields: CasesConfigurationUITemplate['caseFields']) => void; +} + +export const TemplateSelectorComponent: React.FC<Props> = ({ + isLoading, + templates, + onTemplateChange, +}) => { + const [selectedTemplate, onSelectTemplate] = useState<string>(); + + const options: EuiSelectOption[] = templates.map((template) => ({ + text: template.name, + value: template.key, + })); + + const onChange: React.ChangeEventHandler<HTMLSelectElement> = useCallback( + (e) => { + const selectedTemplated = templates.find((template) => template.key === e.target.value); + + if (selectedTemplated) { + onSelectTemplate(selectedTemplated.key); + onTemplateChange(selectedTemplated.caseFields); + } + }, + [onTemplateChange, templates] + ); + + return ( + <EuiFormRow + id="createCaseTemplate" + fullWidth + label={TEMPLATE_LABEL} + labelAppend={OptionalFieldLabel} + helpText={TEMPLATE_HELP_TEXT} + > + <EuiSelect + onChange={onChange} + options={options} + disabled={isLoading} + isLoading={isLoading} + data-test-subj="create-case-template-select" + fullWidth + hasNoInitialSelection + value={selectedTemplate} + /> + </EuiFormRow> + ); +}; + +TemplateSelectorComponent.displayName = 'TemplateSelector'; + +export const TemplateSelector = React.memo(TemplateSelectorComponent); diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 473cc40a6a3f8..aef9c7c525acd 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -11,14 +11,18 @@ export * from '../../common/translations'; export * from '../user_profiles/translations'; export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { - defaultMessage: 'Case fields', + defaultMessage: 'Select template', }); export const STEP_TWO_TITLE = i18n.translate('xpack.cases.create.stepTwoTitle', { - defaultMessage: 'Case settings', + defaultMessage: 'Case fields', }); export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitle', { + defaultMessage: 'Case settings', +}); + +export const STEP_FOUR_TITLE = i18n.translate('xpack.cases.create.stepFourTitle', { defaultMessage: 'External Connector Fields', }); @@ -45,3 +49,15 @@ export const CANCEL_MODAL_BUTTON = i18n.translate('xpack.cases.create.cancelModa export const CONFIRM_MODAL_BUTTON = i18n.translate('xpack.cases.create.confirmModalButton', { defaultMessage: 'Exit without saving', }); + +export const TEMPLATE_LABEL = i18n.translate('xpack.cases.create.templateLabel', { + defaultMessage: 'Template name', +}); + +export const TEMPLATE_HELP_TEXT = i18n.translate('xpack.cases.create.templateHelpText', { + defaultMessage: 'Selecting a template will pre-fill certain case fields below', +}); + +export const SOLUTION_SELECTOR_LABEL = i18n.translate('xpack.cases.create.solutionSelectorLabel', { + defaultMessage: 'Create case under:', +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.test.ts b/x-pack/plugins/cases/public/components/create/utils.test.ts new file mode 100644 index 0000000000000..6b8c9c9017fc4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.test.ts @@ -0,0 +1,383 @@ +/* + * 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 { + getInitialCaseValue, + trimUserFormData, + getOwnerDefaultValue, + createFormDeserializer, + createFormSerializer, +} from './utils'; +import { ConnectorTypes, CaseSeverity, CustomFieldTypes } from '../../../common/types/domain'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; + +describe('utils', () => { + describe('getInitialCaseValue', () => { + it('returns expected initial values', () => { + const params = { + owner: 'foobar', + connector: { + id: 'foo', + name: 'bar', + type: ConnectorTypes.jira as const, + fields: null, + }, + }; + expect(getInitialCaseValue(params)).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + ...params, + }); + }); + + it('returns none connector when none is specified', () => { + expect(getInitialCaseValue({ owner: 'foobar' })).toEqual({ + assignees: [], + category: undefined, + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: '', + owner: 'foobar', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + }); + }); + + it('returns extra fields', () => { + const extraFields = { + owner: 'foobar', + title: 'my title', + assignees: [ + { + uid: 'uid', + }, + ], + tags: ['my tag'], + category: 'categorty', + severity: CaseSeverity.HIGH as const, + description: 'Cool description', + settings: { syncAlerts: false }, + customFields: [{ key: 'key', type: CustomFieldTypes.TEXT as const, value: 'text' }], + }; + + expect(getInitialCaseValue(extraFields)).toEqual({ + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + ...extraFields, + }); + }); + }); + + describe('trimUserFormData', () => { + it('trims applicable fields in the user form data', () => { + const userFormData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + category: userFormData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + + it('ignores category and tags if they are missing', () => { + const userFormData = { + title: ' title ', + description: ' description ', + tags: [], + }; + + expect(trimUserFormData(userFormData)).toEqual({ + title: userFormData.title.trim(), + description: userFormData.description.trim(), + tags: [], + }); + }); + }); + + describe('getOwnerDefaultValue', () => { + it('returns the general cases owner if it exists', () => { + expect(getOwnerDefaultValue(['foobar', GENERAL_CASES_OWNER])).toEqual(GENERAL_CASES_OWNER); + }); + + it('returns the first available owner if the general cases owner is not available', () => { + expect(getOwnerDefaultValue(['foo', 'bar'])).toEqual('foo'); + }); + + it('returns the general cases owner if no owner is available', () => { + expect(getOwnerDefaultValue([])).toEqual(GENERAL_CASES_OWNER); + }); + }); + + describe('createFormSerializer', () => { + const dataToSerialize = { + title: 'title', + description: 'description', + tags: [], + connectorId: '', + fields: { incidentTypes: null, severityCode: null }, + customFields: {}, + syncAlerts: false, + }; + const serializedFormData = { + title: 'title', + description: 'description', + customFields: [], + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + owner: casesConfigurationsMock.owner, + }; + + it('returns empty values with owner and connector from configuration when data is empty', () => { + // @ts-ignore: this is what we are trying to test + expect(createFormSerializer([], casesConfigurationsMock, {})).toEqual({ + assignees: [], + category: undefined, + customFields: [], + description: '', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: [], + title: '', + connector: casesConfigurationsMock.connector, + owner: casesConfigurationsMock.owner, + }); + }); + + it('normalizes action connectors', () => { + expect( + createFormSerializer( + [ + { + id: 'test', + actionTypeId: '.test', + name: 'My connector', + isDeprecated: false, + isPreconfigured: false, + config: { foo: 'bar' }, + isMissingSecrets: false, + isSystemAction: false, + }, + ], + casesConfigurationsMock, + { + ...dataToSerialize, + connectorId: 'test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + } + ) + ).toEqual({ + ...serializedFormData, + connector: { + id: 'test', + name: 'My connector', + type: '.test', + fields: { + issueType: '1', + priority: 'test', + parent: null, + }, + }, + }); + }); + + it('transforms custom fields', () => { + expect( + createFormSerializer([], casesConfigurationsMock, { + ...dataToSerialize, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }) + ).toEqual({ + ...serializedFormData, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ], + }); + }); + + it('trims form data', () => { + const untrimmedData = { + title: ' title ', + description: ' description ', + category: ' category ', + tags: [' tag 1 ', ' tag 2 '], + }; + + expect( + // @ts-ignore: expected incomplete form data + createFormSerializer([], casesConfigurationsMock, { ...dataToSerialize, ...untrimmedData }) + ).toEqual({ + ...serializedFormData, + title: untrimmedData.title.trim(), + description: untrimmedData.description.trim(), + category: untrimmedData.category.trim(), + tags: ['tag 1', 'tag 2'], + }); + }); + }); + + describe('createFormDeserializer', () => { + it('deserializes data as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: {}, + }); + }); + + it('deserializes customFields as expected', () => { + expect( + createFormDeserializer({ + title: 'title', + description: 'description', + settings: { + syncAlerts: false, + }, + tags: [], + connector: { + id: 'foobar', + name: 'none', + type: ConnectorTypes.swimlane as const, + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + }, + owner: casesConfigurationsMock.owner, + customFields: [ + { + key: 'test_key_1', + type: CustomFieldTypes.TEXT, + value: 'first value', + }, + { + key: 'test_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'test_key_3', + type: CustomFieldTypes.TEXT, + value: 'second value', + }, + ], + }) + ).toEqual({ + title: 'title', + description: 'description', + syncAlerts: false, + tags: [], + owner: casesConfigurationsMock.owner, + connectorId: 'foobar', + fields: { + issueType: '1', + priority: 'test', + parent: null, + caseId: null, + }, + customFields: { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/utils.ts b/x-pack/plugins/cases/public/components/create/utils.ts new file mode 100644 index 0000000000000..daeac67066c9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/utils.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import type { CasePostRequest } from '../../../common'; +import { GENERAL_CASES_OWNER } from '../../../common'; +import type { ActionConnector } from '../../../common/types/domain'; +import { CaseSeverity } from '../../../common/types/domain'; +import type { CasesConfigurationUI } from '../../containers/types'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormSerializer, +} from '../utils'; + +type GetInitialCaseValueArgs = Partial<Omit<CasePostRequest, 'owner'>> & + Pick<CasePostRequest, 'owner'>; + +export const getInitialCaseValue = ({ + owner, + connector, + ...restFields +}: GetInitialCaseValueArgs): CasePostRequest => ({ + title: '', + assignees: [], + tags: [], + category: undefined, + severity: CaseSeverity.LOW as const, + description: '', + settings: { syncAlerts: true }, + customFields: [], + ...restFields, + connector: connector ?? getNoneConnector(), + owner, +}); + +export const trimUserFormData = ( + userFormData: Omit< + CaseFormFieldsSchemaProps, + 'connectorId' | 'fields' | 'syncAlerts' | 'customFields' + > +) => { + let formData = { + ...userFormData, + title: userFormData.title.trim(), + description: userFormData.description.trim(), + }; + + if (userFormData.category) { + formData = { ...formData, category: userFormData.category.trim() }; + } + + if (userFormData.tags) { + formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) }; + } + + return formData; +}; + +export const createFormDeserializer = (data: CasePostRequest): CaseFormFieldsSchemaProps => { + const { connector, settings, customFields, ...restData } = data; + + return { + ...restData, + connectorId: connector.id, + fields: connector.fields, + syncAlerts: settings.syncAlerts, + customFields: customFieldsFormDeserializer(customFields) ?? {}, + }; +}; + +export const createFormSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: CaseFormFieldsSchemaProps +): CasePostRequest => { + if (data == null || isEmpty(data)) { + return getInitialCaseValue({ + owner: currentConfiguration.owner, + connector: currentConfiguration.connector, + }); + } + + const { connectorId: dataConnectorId, fields, syncAlerts, customFields, ...restData } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields }); + const caseConnector = getConnectorById(dataConnectorId, connectors); + const connectorToUpdate = caseConnector + ? normalizeActionConnector(caseConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedCustomFields = customFieldsFormSerializer( + customFields, + currentConfiguration.customFields + ); + + const trimmedData = trimUserFormData(restData); + + return { + ...trimmedData, + connector: connectorToUpdate, + settings: { syncAlerts: syncAlerts ?? false }, + owner: currentConfiguration.owner, + customFields: transformedCustomFields, + }; +}; + +export const getOwnerDefaultValue = (availableOwners: string[]) => + availableOwners.includes(GENERAL_CASES_OWNER) + ? GENERAL_CASES_OWNER + : availableOwners[0] ?? GENERAL_CASES_OWNER; 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 3a2c54286cd62..f735a4034f024 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -11,7 +11,7 @@ import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import type { CaseCustomFieldText } from '../../../../common/types/domain'; import type { CustomFieldType } from '../types'; import { getTextFieldConfig } from './config'; -import { OptionalFieldLabel } from '../../create/optional_field_label'; +import { OptionalFieldLabel } from '../../optional_field_label'; const CreateComponent: CustomFieldType<CaseCustomFieldText>['Create'] = ({ customFieldConfiguration, diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts index 3b82eddf55cad..5a21319645836 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { customFieldSerializer, transformCustomFieldsData } from './utils'; +import { customFieldSerializer } from './utils'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; -import { customFieldsConfigurationMock } from '../../containers/mock'; describe('utils ', () => { describe('customFieldSerializer ', () => { @@ -99,54 +98,4 @@ describe('utils ', () => { `); }); }); - - describe('transformCustomFieldsData', () => { - it('transforms customFields correctly', () => { - const customFields = { - test_key_1: 'first value', - test_key_2: true, - test_key_3: 'second value', - }; - - expect(transformCustomFieldsData(customFields, customFieldsConfigurationMock)).toEqual([ - { - key: 'test_key_1', - type: 'text', - value: 'first value', - }, - { - key: 'test_key_2', - type: 'toggle', - value: true, - }, - { - key: 'test_key_3', - type: 'text', - value: 'second value', - }, - ]); - }); - - it('returns empty array when custom fields are empty', () => { - expect(transformCustomFieldsData({}, customFieldsConfigurationMock)).toEqual([]); - }); - - it('returns empty array when not custom fields in the configuration', () => { - const customFields = { - test_key_1: 'first value', - test_key_2: true, - test_key_3: 'second value', - }; - - expect(transformCustomFieldsData(customFields, [])).toEqual([]); - }); - - it('returns empty array when custom fields do not match with configuration', () => { - const customFields = { - random_key: 'first value', - }; - - expect(transformCustomFieldsData(customFields, customFieldsConfigurationMock)).toEqual([]); - }); - }); }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts index e0abfb3e0411e..3842b75b5a7ea 100644 --- a/x-pack/plugins/cases/public/components/custom_fields/utils.ts +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -8,8 +8,6 @@ import { isEmptyString } from '@kbn/es-ui-shared-plugin/static/validators/string'; import { isString } from 'lodash'; import type { CustomFieldConfiguration } from '../../../common/types/domain'; -import type { CasesConfigurationUI, CaseUI, CaseUICustomField } from '../../containers/types'; -import { convertCustomFieldValue } from '../utils'; export const customFieldSerializer = ( field: CustomFieldConfiguration @@ -22,27 +20,3 @@ export const customFieldSerializer = ( return field; }; - -export const transformCustomFieldsData = ( - customFields: Record<string, string | boolean>, - selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] -) => { - const transformedCustomFields: CaseUI['customFields'] = []; - - if (!customFields || !selectedCustomFieldsConfiguration.length) { - return []; - } - - for (const [key, value] of Object.entries(customFields)) { - const configCustomField = selectedCustomFieldsConfiguration.find((item) => item.key === key); - if (configCustomField) { - transformedCustomFields.push({ - key: configCustomField.key, - type: configCustomField.type, - value: convertCustomFieldValue(value), - } as CaseUICustomField); - } - } - - return transformedCustomFields; -}; diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.test.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.test.tsx diff --git a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx similarity index 89% rename from x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx rename to x-pack/plugins/cases/public/components/optional_field_label/index.tsx index ea994b2219961..98c101440116a 100644 --- a/x-pack/plugins/cases/public/components/create/optional_field_label/index.tsx +++ b/x-pack/plugins/cases/public/components/optional_field_label/index.tsx @@ -8,7 +8,7 @@ import { EuiText } from '@elastic/eui'; import React from 'react'; -import * as i18n from '../../../common/translations'; +import * as i18n from '../../common/translations'; export const OptionalFieldLabel = ( <EuiText color="subdued" size="xs" data-test-subj="form-optional-field-label"> 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 4754675b6abe4..a01aa25132cb5 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -88,6 +88,7 @@ describe('TemplateForm', () => { key: 'template_key_1', name: 'Template 1', description: 'Sample description', + caseFields: null, }, }; appMockRenderer.render(<TemplateForm {...newProps} />); @@ -176,16 +177,21 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'this is a first template', name: 'Template 1', - templateDescription: 'this is a first template', - templateTags: ['foo', 'bar'], - title: '', - description: '', - severity: '', - tags: [], - connectorId: 'none', - syncAlerts: true, - category: null, + tags: ['foo', 'bar'], }); }); }); @@ -216,16 +222,21 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: 'This is a first test template', name: 'First test template', - title: '', - description: '', - templateDescription: 'This is a first test template', - tags: [], - connectorId: 'none', - severity: '', - syncAlerts: true, - category: null, - templateTags: ['foo', 'bar'], + tags: ['foo', 'bar'], }); }); }); @@ -239,7 +250,7 @@ describe('TemplateForm', () => { <TemplateForm {...{ ...defaultProps, - initialValue: { key: 'template_1_key', name: 'Template 1' }, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, onChange: onChangeState, }} /> @@ -272,16 +283,25 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'This is a case description', + settings: { + syncAlerts: true, + }, + tags: ['template-1'], + title: 'Case with Template 1', + }, + description: undefined, name: 'Template 1', - templateDescription: '', - templateTags: [], - title: 'Case with Template 1', - description: 'This is a case description', - tags: ['template-1'], - severity: '', - category: 'new', - connectorId: 'none', - syncAlerts: true, + tags: [], }); }); }); @@ -312,16 +332,25 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + description: 'case desc', + settings: { + syncAlerts: true, + }, + severity: 'low', + tags: ['sample-4'], + title: 'Case with sample template 4', + }, + description: 'This is a fourth test template', 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'], + tags: ['foo', 'bar'], }); }); }); @@ -335,7 +364,7 @@ describe('TemplateForm', () => { <TemplateForm {...{ ...defaultProps, - initialValue: { key: 'template_1_key', name: 'Template 1' }, + initialValue: { key: 'template_1_key', name: 'Template 1', caseFields: null }, currentConfiguration: { ...defaultProps.currentConfiguration, connector: { @@ -350,20 +379,12 @@ describe('TemplateForm', () => { /> ); - const connectors = await screen.findByTestId('caseConnectors'); - - expect(await within(connectors).findByTestId('form-optional-field-label')).toBeInTheDocument(); + await screen.findByTestId('caseConnectors'); await waitFor(() => { expect(formState).not.toBeUndefined(); }); - expect(await screen.findByTestId('connector-fields-sn-itsm')).toBeInTheDocument(); - - userEvent.selectOptions(await screen.findByTestId('urgencySelect'), '1'); - - userEvent.selectOptions(await screen.findByTestId('categorySelect'), ['software']); - await act(async () => { const { data, isValid } = await formState!.submit(); @@ -371,23 +392,21 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, name: 'Template 1', tags: [], - templateDescription: '', - templateTags: [], - title: '', - description: '', - category: null, - severity: '', - connectorId: 'servicenow-1', - fields: { - category: 'software', - urgency: '1', - impact: '', - severity: '', - subcategory: null, - }, - syncAlerts: true, }); }); }); @@ -442,23 +461,27 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: { + category: 'Denial of Service', + impact: null, + severity: null, + subcategory: null, + urgency: null, + }, + id: 'servicenow-1', + name: 'My SN connector', + type: '.servicenow', + }, + customFields: [], + settings: { + syncAlerts: true, + }, + }, + description: undefined, 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, }); }); }); @@ -475,6 +498,7 @@ describe('TemplateForm', () => { initialValue: { key: 'template_1_key', name: 'Template 1', + caseFields: null, }, currentConfiguration: { ...defaultProps.currentConfiguration, @@ -519,22 +543,37 @@ describe('TemplateForm', () => { expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'My text test value 1', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: true, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, name: 'Template 1', 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, - }, }); }); }); @@ -552,12 +591,12 @@ describe('TemplateForm', () => { caseFields: { customFields: [ { - type: CustomFieldTypes.TEXT, + type: CustomFieldTypes.TEXT as const, key: 'test_key_1', value: 'this is my first custom field value', }, { - type: CustomFieldTypes.TOGGLE, + type: CustomFieldTypes.TOGGLE as const, key: 'test_key_2', value: false, }, @@ -586,25 +625,39 @@ describe('TemplateForm', () => { const { data, isValid } = await formState!.submit(); expect(isValid).toBe(true); - expect(data).toEqual({ key: expect.anything(), + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [ + { + key: 'test_key_1', + type: 'text', + value: 'this is my first custom field value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_4', + type: 'toggle', + value: false, + }, + ], + settings: { + syncAlerts: true, + }, + }, + description: undefined, 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 2e5149aa76efe..acd6855fe4706 100644 --- a/x-pack/plugins/cases/public/components/templates/form.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.tsx @@ -8,17 +8,17 @@ import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import React, { useEffect, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import type { ActionConnector } from '../../../common/types/domain'; +import type { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; import type { FormState } from '../configure_cases/flyout'; import { schema } from './schema'; import { FormFields } from './form_fields'; -import { templateDeserializer } from './utils'; +import { templateDeserializer, templateSerializer } from './utils'; import type { TemplateFormProps } from './types'; import type { CasesConfigurationUI } from '../../containers/types'; interface Props { - onChange: (state: FormState<TemplateFormProps>) => void; - initialValue: TemplateFormProps | null; + onChange: (state: FormState<TemplateConfiguration, TemplateFormProps>) => void; + initialValue: TemplateConfiguration | null; connectors: ActionConnector[]; currentConfiguration: CasesConfigurationUI; isEditMode?: boolean; @@ -39,11 +39,15 @@ const FormComponent: React.FC<Props> = ({ name: '', description: '', tags: [], - caseFields: null, + caseFields: { + connector: currentConfiguration.connector, + }, }, options: { stripEmptyFields: false }, schema, deserializer: templateDeserializer, + serializer: (data: TemplateFormProps) => + templateSerializer(connectors, currentConfiguration, data), }); const { submit, isValid, isSubmitting } = form; diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx index 61c709dd7a027..814ba13efe6ed 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 @@ -186,7 +186,10 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > <FormFields {...newProps} /> </FormTestComponent> ); @@ -355,7 +358,10 @@ describe('form fields', () => { }; appMockRenderer.render( - <FormTestComponent formDefaultValue={formDefaultValue} onSubmit={onSubmit}> + <FormTestComponent + formDefaultValue={{ ...formDefaultValue, connectorId: 'servicenow-1' }} + onSubmit={onSubmit} + > <FormFields {...newProps} /> </FormTestComponent> ); diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.tsx index 1f47407d241bb..9f28f7b7179b4 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.tsx @@ -11,12 +11,12 @@ import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiSteps } from '@elastic/eui'; import { CaseFormFields } from '../case_form_fields'; import * as i18n from './translations'; -import { Connector } from './connector'; import type { ActionConnector } from '../../containers/configure/types'; import type { CasesConfigurationUI } from '../../containers/types'; import { TemplateFields } from './template_fields'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { SyncAlertsToggle } from '../create/sync_alerts_toggle'; +import { SyncAlertsToggle } from '../case_form_fields/sync_alerts_toggle'; +import { Connector } from '../case_form_fields/connector'; interface FormFieldsProps { isSubmitting?: boolean; @@ -32,7 +32,7 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ isEditMode, }) => { const { isSyncAlertsEnabled } = useCasesFeatures(); - const { customFields: configurationCustomFields, connector, templates } = currentConfiguration; + const { customFields: configurationCustomFields, templates } = currentConfiguration; const configurationTemplateTags = templates .map((template) => (template?.tags?.length ? template.tags : [])) .flat(); @@ -77,15 +77,10 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ () => ({ title: i18n.CONNECTOR_FIELDS, children: ( - <Connector - connectors={connectors} - isLoading={isSubmitting} - configurationConnectorId={connector.id} - isEditMode={isEditMode} - /> + <Connector connectors={connectors} isLoading={isSubmitting} isLoadingConnectors={false} /> ), }), - [connectors, connector, isSubmitting, isEditMode] + [connectors, isSubmitting] ); const allSteps = useMemo( @@ -96,7 +91,6 @@ const FormFieldsComponent: React.FC<FormFieldsProps> = ({ return ( <> <UseField path="key" component={HiddenField} /> - <EuiSteps headingElement="h2" steps={allSteps} 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 2745741fb87a7..ca4cb4c3caf83 100644 --- a/x-pack/plugins/cases/public/components/templates/index.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/index.test.tsx @@ -119,7 +119,7 @@ describe('Templates', () => { it('shows error when templates reaches the limit', async () => { const mockTemplates = []; - for (let i = 0; i < 6; i++) { + for (let i = 0; i < MAX_TEMPLATES_LENGTH; i++) { mockTemplates.push({ key: `field_key_${i + 1}`, name: `template_${i + 1}`, @@ -127,9 +127,8 @@ describe('Templates', () => { caseFields: null, }); } - const templates = [...templatesConfigurationMock, ...mockTemplates]; - appMockRender.render(<Templates {...{ ...props, templates }} />); + appMockRender.render(<Templates {...{ ...props, templates: mockTemplates }} />); userEvent.click(await screen.findByTestId('add-template')); diff --git a/x-pack/plugins/cases/public/components/templates/schema.test.tsx b/x-pack/plugins/cases/public/components/templates/schema.test.tsx index cb35e758d6f56..3e572068b5fdc 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.test.tsx @@ -5,98 +5,110 @@ * 2.0. */ -import { CaseFormFieldsSchemaWithOptionalLabel } from './schema'; +import { caseFormFieldsSchemaWithOptionalLabel } from './schema'; describe('Template schema', () => { - describe('CaseFormFieldsSchemaWithOptionalLabel', () => { + describe('caseFormFieldsSchemaWithOptionalLabel', () => { it('has label append for each field', () => { - expect(CaseFormFieldsSchemaWithOptionalLabel).toMatchInlineSnapshot(` - Object { - "assignees": Object { - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - }, - "category": Object { - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - }, - "description": Object { - "label": "Description", - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - "validations": Array [ - Object { - "validator": [Function], - }, - ], - }, - "severity": Object { - "label": "Severity", - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - }, - "tags": Object { - "helpText": "Separate tags with a line break.", - "label": "Tags", - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - "validations": Array [ - Object { - "isBlocking": false, - "type": "arrayItem", - "validator": [Function], - }, - Object { - "isBlocking": false, - "type": "arrayItem", - "validator": [Function], - }, - Object { - "validator": [Function], - }, - ], - }, - "title": Object { - "label": "Name", - "labelAppend": <EuiText - color="subdued" - data-test-subj="form-optional-field-label" - size="xs" - > - Optional - </EuiText>, - "validations": Array [ - Object { - "validator": [Function], - }, - ], - }, - } + expect(caseFormFieldsSchemaWithOptionalLabel).toMatchInlineSnapshot(` + Object { + "assignees": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "category": Object { + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "connectorId": Object { + "defaultValue": "none", + "label": "External incident management system", + }, + "customFields": Object {}, + "description": Object { + "label": "Description", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + "fields": Object { + "defaultValue": null, + }, + "severity": Object { + "label": "Severity", + }, + "syncAlerts": Object { + "defaultValue": true, + "helpText": "Enabling this option will sync the alert statuses with the case status.", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + }, + "tags": Object { + "helpText": "Separate tags with a line break.", + "label": "Tags", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "isBlocking": false, + "type": "arrayItem", + "validator": [Function], + }, + Object { + "validator": [Function], + }, + ], + }, + "title": Object { + "label": "Name", + "labelAppend": <EuiText + color="subdued" + data-test-subj="form-optional-field-label" + size="xs" + > + Optional + </EuiText>, + "validations": Array [ + Object { + "validator": [Function], + }, + ], + }, + } `); }); }); diff --git a/x-pack/plugins/cases/public/components/templates/schema.tsx b/x-pack/plugins/cases/public/components/templates/schema.tsx index 7fd0347025dc1..2c51bc8827b3b 100644 --- a/x-pack/plugins/cases/public/components/templates/schema.tsx +++ b/x-pack/plugins/cases/public/components/templates/schema.tsx @@ -14,7 +14,7 @@ import { MAX_TEMPLATE_NAME_LENGTH, MAX_TEMPLATE_DESCRIPTION_LENGTH, } from '../../../common/constants'; -import { OptionalFieldLabel } from '../create/optional_field_label'; +import { OptionalFieldLabel } from '../optional_field_label'; import * as i18n from './translations'; import type { TemplateFormProps } from './types'; import { @@ -22,13 +22,15 @@ import { validateMaxLength, validateMaxTagsLength, } from '../case_form_fields/utils'; -import { schema as CaseFormFieldsSchema } from '../case_form_fields/schema'; +import { schema as caseFormFieldsSchema } from '../case_form_fields/schema'; const { emptyField, maxLengthField } = fieldValidators; +const nonOptionalFields = ['connectorId', 'fields', 'severity', 'customFields']; + // add optional label to all case form fields -export const CaseFormFieldsSchemaWithOptionalLabel = Object.fromEntries( - Object.entries(CaseFormFieldsSchema).map(([key, value]) => { - if (typeof value === 'object') { +export const caseFormFieldsSchemaWithOptionalLabel = Object.fromEntries( + Object.entries(caseFormFieldsSchema).map(([key, value]) => { + if (typeof value === 'object' && !nonOptionalFields.includes(key)) { const updatedValue = { ...value, labelAppend: OptionalFieldLabel }; return [key, updatedValue]; } @@ -102,17 +104,5 @@ export const schema: FormSchema<TemplateFormProps> = { }, ], }, - connectorId: { - labelAppend: OptionalFieldLabel, - label: i18n.CONNECTORS, - }, - fields: { - defaultValue: null, - }, - syncAlerts: { - helpText: i18n.SYNC_ALERTS_HELP, - labelAppend: OptionalFieldLabel, - defaultValue: true, - }, - ...CaseFormFieldsSchemaWithOptionalLabel, + ...caseFormFieldsSchemaWithOptionalLabel, }; 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 adc817cee41c1..2f989201437c3 100644 --- a/x-pack/plugins/cases/public/components/templates/template_fields.tsx +++ b/x-pack/plugins/cases/public/components/templates/template_fields.tsx @@ -9,14 +9,14 @@ import React, { memo } from 'react'; import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { TextField, TextAreaField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiFlexGroup } from '@elastic/eui'; -import { OptionalFieldLabel } from '../create/optional_field_label'; +import { OptionalFieldLabel } from '../optional_field_label'; import { TemplateTags } from './template_tags'; const TemplateFieldsComponent: React.FC<{ isLoading: boolean; configurationTemplateTags: string[]; }> = ({ isLoading = false, configurationTemplateTags }) => ( - <EuiFlexGroup data-test-subj="template-fields" direction="column"> + <EuiFlexGroup data-test-subj="template-fields" direction="column" gutterSize="none"> <UseField path="name" component={TextField} 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 15d78aeb3107c..553fcdd06297e 100644 --- a/x-pack/plugins/cases/public/components/templates/templates_list.tsx +++ b/x-pack/plugins/cases/public/components/templates/templates_list.tsx @@ -30,18 +30,18 @@ export interface Props { const TemplatesListComponent: React.FC<Props> = (props) => { const { templates, onEditTemplate, onDeleteTemplate } = props; const { euiTheme } = useEuiTheme(); - const [itemToBeDeleted, SetItemToBeDeleted] = useState<TemplateConfiguration | null>(null); + const [itemToBeDeleted, setItemToBeDeleted] = useState<TemplateConfiguration | null>(null); const onConfirm = useCallback(() => { if (itemToBeDeleted) { onDeleteTemplate(itemToBeDeleted.key); } - SetItemToBeDeleted(null); - }, [onDeleteTemplate, SetItemToBeDeleted, itemToBeDeleted]); + setItemToBeDeleted(null); + }, [onDeleteTemplate, setItemToBeDeleted, itemToBeDeleted]); const onCancel = useCallback(() => { - SetItemToBeDeleted(null); + setItemToBeDeleted(null); }, []); const showModal = Boolean(itemToBeDeleted); @@ -101,7 +101,7 @@ const TemplatesListComponent: React.FC<Props> = (props) => { aria-label={`${template.key}-template-delete`} iconType="minusInCircle" color="danger" - onClick={() => SetItemToBeDeleted(template)} + onClick={() => setItemToBeDeleted(template)} /> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/cases/public/components/templates/types.ts b/x-pack/plugins/cases/public/components/templates/types.ts index eba31a80ebe4e..cf1187ed64e2d 100644 --- a/x-pack/plugins/cases/public/components/templates/types.ts +++ b/x-pack/plugins/cases/public/components/templates/types.ts @@ -5,24 +5,11 @@ * 2.0. */ -import type { - CaseBaseOptionalFields, - ConnectorTypeFields, - TemplateConfiguration, -} from '../../../common/types/domain'; - -export type CaseFieldsProps = Omit< - CaseBaseOptionalFields, - 'customFields' | 'connector' | 'settings' -> & { - customFields?: Record<string, string | boolean>; - connectorId?: string; - fields?: ConnectorTypeFields['fields']; - syncAlerts?: boolean; -}; +import type { TemplateConfiguration } from '../../../common/types/domain'; +import type { CaseFormFieldsSchemaProps } from '../case_form_fields/schema'; export type TemplateFormProps = Pick<TemplateConfiguration, 'key' | 'name'> & - CaseFieldsProps & { + Partial<CaseFormFieldsSchemaProps> & { templateTags?: string[]; templateDescription?: string; }; diff --git a/x-pack/plugins/cases/public/components/templates/utils.test.ts b/x-pack/plugins/cases/public/components/templates/utils.test.ts index e23e6f56b257f..9e3cd70c120af 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.test.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.test.ts @@ -5,22 +5,23 @@ * 2.0. */ -import type { CaseUI } from '../../../common'; -import { CaseSeverity } from '../../../common'; +import { CaseSeverity, ConnectorTypes } from '../../../common'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; +import type { CaseUI } from '../../containers/types'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; import { convertTemplateCustomFields, - getTemplateSerializedData, removeEmptyFields, templateDeserializer, + templateSerializer, } from './utils'; -import { userProfiles } from '../../containers/user_profiles/api.mock'; -import { customFieldsConfigurationMock } from '../../containers/mock'; -import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; describe('utils', () => { describe('getTemplateSerializedData', () => { it('serializes empty fields correctly', () => { - const res = getTemplateSerializedData({ + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { key: '', name: '', templateDescription: '', @@ -32,11 +33,28 @@ describe('utils', () => { category: null, }); - expect(res).toEqual({ fields: null }); + expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], + }); }); it('serializes connectors fields correctly', () => { - const res = getTemplateSerializedData({ + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { key: '', name: '', templateDescription: '', @@ -44,12 +62,27 @@ describe('utils', () => { }); expect(res).toEqual({ - fields: null, + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, + key: '', + name: '', + tags: [], }); }); it('serializes non empty fields correctly', () => { - const res = getTemplateSerializedData({ + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { key: 'key_1', name: 'template 1', templateDescription: 'description 1', @@ -58,17 +91,28 @@ describe('utils', () => { }); expect(res).toEqual({ + caseFields: { + category: 'new', + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: 'description 1', key: 'key_1', name: 'template 1', - templateDescription: 'description 1', - category: 'new', - templateTags: ['sample'], - fields: null, + tags: ['sample'], }); }); it('serializes custom fields correctly', () => { - const res = getTemplateSerializedData({ + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { key: 'key_1', name: 'template 1', templateDescription: '', @@ -80,18 +124,27 @@ describe('utils', () => { }); expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, key: 'key_1', name: 'template 1', - customFields: { - custom_field_1: 'foobar', - custom_field_3: true, - }, - fields: null, + tags: [], }); }); it('serializes connector fields correctly', () => { - const res = getTemplateSerializedData({ + const res = templateSerializer(connectorsMock, casesConfigurationsMock, { key: 'key_1', name: 'template 1', templateDescription: '', @@ -105,15 +158,22 @@ describe('utils', () => { }); expect(res).toEqual({ + caseFields: { + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + settings: { + syncAlerts: false, + }, + }, + description: undefined, key: 'key_1', name: 'template 1', - fields: { - impact: 'high', - severity: 'low', - category: null, - urgency: null, - subcategory: null, - }, + tags: [], }); }); }); diff --git a/x-pack/plugins/cases/public/components/templates/utils.ts b/x-pack/plugins/cases/public/components/templates/utils.ts index d118942e93071..3ee3002388e2d 100644 --- a/x-pack/plugins/cases/public/components/templates/utils.ts +++ b/x-pack/plugins/cases/public/components/templates/utils.ts @@ -6,9 +6,16 @@ */ import { isEmpty } from 'lodash'; -import type { TemplateConfiguration } from '../../../common/types/domain'; -import type { CaseUI } from '../../containers/types'; -import { getConnectorsFormDeserializer, getConnectorsFormSerializer } from '../utils'; +import type { ActionConnector, TemplateConfiguration } from '../../../common/types/domain'; +import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; +import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils'; +import { + customFieldsFormDeserializer, + customFieldsFormSerializer, + getConnectorById, + getConnectorsFormDeserializer, + getConnectorsFormSerializer, +} from '../utils'; import type { TemplateFormProps } from './types'; export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { @@ -41,39 +48,73 @@ export const convertTemplateCustomFields = ( }; 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, - }; + if (data == null) { + return data; } - return data; -}; + const { key, name, description, tags: templateTags, caseFields } = data; + const { connector, customFields, settings, tags, ...rest } = caseFields ?? {}; + const connectorFields = getConnectorsFormDeserializer({ fields: connector?.fields ?? null }); + const convertedCustomFields = customFieldsFormDeserializer(customFields); -export const getTemplateSerializedData = (data: TemplateFormProps): TemplateFormProps => { - if (data !== null) { - const { fields = null, ...rest } = data; - const connectorFields = getConnectorsFormSerializer({ fields }); - const serializedFields = removeEmptyFields({ ...rest }); + return { + key, + name, + templateDescription: description ?? '', + templateTags: templateTags ?? [], + connectorId: connector?.id ?? 'none', + fields: connectorFields.fields ?? null, + customFields: convertedCustomFields ?? {}, + tags: tags ?? [], + ...rest, + }; +}; - return { - ...serializedFields, - fields: connectorFields.fields, - } as TemplateFormProps; +export const templateSerializer = ( + connectors: ActionConnector[], + currentConfiguration: CasesConfigurationUI, + data: TemplateFormProps +): TemplateConfiguration => { + if (data == null) { + return data; } - return data; + const { fields: connectorFields = null, key, name, ...rest } = data; + + const serializedConnectorFields = getConnectorsFormSerializer({ fields: connectorFields }); + const nonEmptyFields = removeEmptyFields({ ...rest }); + + const { + connectorId, + customFields: templateCustomFields, + syncAlerts = false, + templateTags, + templateDescription, + ...otherCaseFields + } = nonEmptyFields; + + const transformedCustomFields = templateCustomFields + ? customFieldsFormSerializer(templateCustomFields, currentConfiguration.customFields) + : []; + + const templateConnector = connectorId ? getConnectorById(connectorId, connectors) : null; + + const transformedConnector = templateConnector + ? normalizeActionConnector(templateConnector, serializedConnectorFields.fields) + : getNoneConnector(); + + const transformedData: TemplateConfiguration = { + key, + name, + description: templateDescription, + tags: templateTags ?? [], + caseFields: { + ...otherCaseFields, + connector: transformedConnector, + customFields: transformedCustomFields, + settings: { syncAlerts }, + }, + }; + + return transformedData; }; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 8f3ef31bdfc4c..005f15b78b3d7 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -29,6 +29,8 @@ import { parseCaseUsers, convertCustomFieldValue, addOrReplaceField, + removeEmptyFields, + customFieldsFormSerializer, } from './utils'; describe('Utils', () => { @@ -725,4 +727,85 @@ describe('Utils', () => { ); }); }); + + describe('removeEmptyFields', () => { + it('removes empty fields', () => { + const res = removeEmptyFields({ + key: '', + name: '', + templateDescription: '', + title: '', + description: '', + templateTags: [], + tags: [], + fields: null, + }); + + expect(res).toEqual({}); + }); + + it('does not remove not empty fields', () => { + const res = removeEmptyFields({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + + expect(res).toEqual({ + key: 'key_1', + name: 'template 1', + templateDescription: 'description 1', + }); + }); + }); + + describe('customFieldsFormSerializer', () => { + it('transforms customFields correctly', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([ + { + key: 'test_key_1', + type: 'text', + value: 'first value', + }, + { + key: 'test_key_2', + type: 'toggle', + value: true, + }, + { + key: 'test_key_3', + type: 'text', + value: 'second value', + }, + ]); + }); + + it('returns empty array when custom fields are empty', () => { + expect(customFieldsFormSerializer({}, customFieldsConfigurationMock)).toEqual([]); + }); + + it('returns empty array when not custom fields in the configuration', () => { + const customFields = { + test_key_1: 'first value', + test_key_2: true, + test_key_3: 'second value', + }; + + expect(customFieldsFormSerializer(customFields, [])).toEqual([]); + }); + + it('returns empty array when custom fields do not match with configuration', () => { + const customFields = { + random_key: 'first value', + }; + + expect(customFieldsFormSerializer(customFields, customFieldsConfigurationMock)).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index b851368368c22..7e1aa54554f50 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -17,7 +17,13 @@ import { ConnectorTypes } from '../../common/types/domain'; import type { CasesPublicStartDependencies } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import type { CaseActionConnector } from './types'; -import type { CaseUser, CaseUsers } from '../../common/ui/types'; +import type { + CasesConfigurationUI, + CaseUI, + CaseUICustomField, + CaseUser, + CaseUsers, +} from '../../common/ui/types'; import { convertToCaseUserWithProfileInfo } from './user_profiles/user_converter'; import type { CaseUserWithProfileInfo } from './user_profiles/types'; @@ -251,3 +257,56 @@ export const addOrReplaceField = <T extends { key: string }>(fields: T[], fieldT return fieldToAdd; }); }; + +export function removeEmptyFields<T extends Record<string, unknown>>(obj: T): Partial<T> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, value]) => !isEmpty(value) || typeof value === 'boolean') + .map(([key, value]) => [ + key, + value === Object(value) && !Array.isArray(value) + ? removeEmptyFields(value as Record<string, unknown>) + : value, + ]) + ) as T; +} + +export const customFieldsFormDeserializer = ( + customFields?: CaseUI['customFields'] +): Record<string, string | boolean> | null => { + if (!customFields || !customFields.length) { + return null; + } + + return customFields.reduce((acc, customField) => { + const initial = { + [customField.key]: customField.value, + }; + + return { ...acc, ...initial }; + }, {}); +}; + +export const customFieldsFormSerializer = ( + customFields: Record<string, string | boolean>, + selectedCustomFieldsConfiguration: CasesConfigurationUI['customFields'] +): CaseUI['customFields'] => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !selectedCustomFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = selectedCustomFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx index e98d63debce4b..0fd0ca642baf2 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_get_supported_action_connectors.tsx @@ -27,6 +27,7 @@ export function useGetSupportedActionConnectors() { return getSupportedActionConnectors({ signal }); }, { + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 2fdd82129825e..8d2feca6b9be0 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -1231,4 +1231,31 @@ export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ }, }, }, + { + key: 'test_template_5', + name: 'Fifth test template', + description: 'This is a fifth test template', + tags: ['foo', 'bar'], + caseFields: { + title: 'Case with sample template 5', + description: 'case desc', + severity: CaseSeverity.HIGH, + category: 'my category', + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'jira-1', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + }, + }, + }, ]; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts index 5732085d99c8e..4edc105b8d349 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts @@ -114,4 +114,14 @@ describe.skip('useBulkGetUserProfiles', () => { expect(addError).toHaveBeenCalled(); }); + + it('does not call the bulkGetUserProfiles if the array of uids is empty', async () => { + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + + renderHook(() => useBulkGetUserProfiles({ uids: [] }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(spyOnBulkGetUserProfiles).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts index a9e60f3e854a9..8b1b9580ca84f 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts @@ -34,6 +34,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { select: profilesToMap, retry: false, keepPreviousData: true, + staleTime: 60 * 1000, // one minute onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( @@ -44,6 +45,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { ); } }, + enabled: uids.length > 0, } ); }; diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index fb018615dd194..3f7b6e1e65f94 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -58,6 +58,10 @@ export function CasesCreateViewServiceProvider( category, owner, }: CreateCaseParams) { + if (owner) { + await this.setSolution(owner); + } + await this.setTitle(title); await this.setDescription(description); await this.setTags(tag); @@ -70,10 +74,6 @@ export function CasesCreateViewServiceProvider( await this.setSeverity(severity); } - if (owner) { - await this.setSolution(owner); - } - await this.submitCase(); }, @@ -96,7 +96,8 @@ export function CasesCreateViewServiceProvider( }, async setSolution(owner: string) { - await testSubjects.click(`${owner}RadioButton`); + await testSubjects.click('caseOwnerSuperSelect'); + await testSubjects.click(`${owner}OwnerOption`); }, async setSeverity(severity: CaseSeverity) { diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index cbd2537179040..c9a16b6e45983 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -150,7 +150,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); @@ -207,7 +207,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts index 8c4dd47532255..c714cdba25637 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/attachment_framework.ts @@ -235,8 +235,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('renders solutions selection', async () => { await openFlyout(); + await testSubjects.click('caseOwnerSelector'); + for (const owner of TOTAL_OWNERS) { - await testSubjects.existOrFail(`${owner}RadioButton`); + await testSubjects.existOrFail(`${owner}OwnerOption`); } await closeFlyout(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts index 88f2663a10749..bafec5b19c525 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts @@ -97,7 +97,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts index 27e4fda20f5ec..4662e96c401f2 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts @@ -97,7 +97,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await cases.create.openCreateCasePage(); // verify custom fields on create case page - await testSubjects.existOrFail('create-case-custom-fields'); + await testSubjects.existOrFail('caseCustomFields'); await cases.create.setTitle(caseTitle); await cases.create.setDescription('this is a test description');