[Security Solutino][Case] Case connector alert UI (#82405)

Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-12-01 23:39:27 +02:00 committed by GitHub
parent 49f0ca0827
commit b9a64ba7d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 2036 additions and 626 deletions

View file

@ -160,6 +160,7 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID];
/*
Rule notifications options
*/
export const ENABLE_CASE_CONNECTOR = false;
export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.email',
'.slack',
@ -169,6 +170,11 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.jira',
'.resilient',
];
if (ENABLE_CASE_CONNECTOR) {
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.case');
}
export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions';
export const NOTIFICATION_THROTTLE_RULE = 'rule';

View file

@ -6,42 +6,24 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor, act } from '@testing-library/react';
import { AddComment, AddCommentRefObject } from '.';
import { TestProviders } from '../../../common/mock';
import { getFormMock } from '../__mock__/form';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { CommentRequest, CommentType } from '../../../../../case/common/api';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { useInsertTimeline } from '../use_insert_timeline';
import { usePostComment } from '../../containers/use_post_comment';
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
import { waitFor } from '@testing-library/react';
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_post_comment');
jest.mock('../use_insert_timeline');
const useFormMock = useForm as jest.Mock;
const useFormDataMock = useFormData as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const usePostCommentMock = usePostComment as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const onCommentSaving = jest.fn();
const onCommentPosted = jest.fn();
const postComment = jest.fn();
const handleCursorChange = jest.fn();
const handleOnTimelineChange = jest.fn();
const addCommentProps = {
caseId: '1234',
@ -52,15 +34,6 @@ const addCommentProps = {
showLoading: false,
};
const defaultInsertTimeline = {
cursorPosition: {
start: 0,
end: 0,
},
handleCursorChange,
handleOnTimelineChange,
};
const defaultPostCommment = {
isLoading: false,
isError: false,
@ -73,14 +46,9 @@ const sampleData: CommentRequest = {
};
describe('AddComment ', () => {
const formHookMock = getFormMock(sampleData);
beforeEach(() => {
jest.resetAllMocks();
useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline);
usePostCommentMock.mockImplementation(() => defaultPostCommment);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ comment: sampleData.comment }]);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
});
@ -92,14 +60,25 @@ describe('AddComment ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
});
expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy();
wrapper.find(`[data-test-subj="submit-comment"]`).first().simulate('click');
await act(async () => {
wrapper.find(`[data-test-subj="submit-comment"]`).first().simulate('click');
});
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(postComment).toBeCalledWith(sampleData, onCommentPosted);
expect(formHookMock.reset).toBeCalled();
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
});
});
@ -112,6 +91,7 @@ describe('AddComment ', () => {
</Router>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
@ -127,15 +107,16 @@ describe('AddComment ', () => {
</Router>
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
).toBeTruthy();
});
it('should insert a quote', () => {
it('should insert a quote', async () => {
const sampleQuote = 'what a cool quote';
const ref = React.createRef<AddCommentRefObject>();
mount(
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<AddComment {...{ ...addCommentProps }} ref={ref} />
@ -143,10 +124,37 @@ describe('AddComment ', () => {
</TestProviders>
);
ref.current!.addQuote(sampleQuote);
expect(formHookMock.setFieldValue).toBeCalledWith(
'comment',
await act(async () => {
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
});
await act(async () => {
ref.current!.addQuote(sampleQuote);
});
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(
`${sampleData.comment}\n\n${sampleQuote}`
);
});
it('it should insert a timeline', async () => {
useInsertTimelineMock.mockImplementation((comment, onTimelineAttached) => {
onTimelineAttached(`[title](url)`);
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<AddComment {...{ ...addCommentProps }} />
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('[title](url)');
});
});
});

View file

@ -12,12 +12,11 @@ import { CommentType } from '../../../../../case/common/api';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
import * as i18n from './translations';
import { schema, AddCommentFormSchema } from './schema';
import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click';
import { useInsertTimeline } from '../use_insert_timeline';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@ -56,12 +55,6 @@ export const AddComment = React.memo(
const { setFieldValue, reset, submit } = form;
const [{ comment }] = useFormData<{ comment: string }>({ form, watch: [fieldName] });
const onCommentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
setFieldValue,
]);
const { handleCursorChange } = useInsertTimeline(comment, onCommentChange);
const addQuote = useCallback(
(quote) => {
setFieldValue(fieldName, `${comment}${comment.length > 0 ? '\n\n' : ''}${quote}`);
@ -73,7 +66,12 @@ export const AddComment = React.memo(
addQuote,
}));
const handleTimelineClick = useTimelineClick();
const onTimelineAttached = useCallback(
(newValue: string) => setFieldValue(fieldName, newValue),
[setFieldValue]
);
useInsertTimeline(comment ?? '', onTimelineAttached);
const onSubmit = useCallback(async () => {
const { isValid, data } = await submit();
@ -98,8 +96,6 @@ export const AddComment = React.memo(
isDisabled: isLoading,
dataTestSubj: 'add-comment',
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
onCursorPositionUpdate: handleCursorChange,
onClickTimeline: handleTimelineClick,
bottomRightContent: (
<EuiButton
data-test-subj="submit-comment"

View file

@ -13,7 +13,6 @@ import { TestProviders } from '../../../common/mock';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases';
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');

View file

@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui
import styled from 'styled-components';
import { ActionConnector } from '../../containers/configure/types';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { connectorsConfiguration } from '../connectors';
import * as i18n from './translations';
export interface Props {

View file

@ -7,8 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { createDefaultMapping } from '../../../common/lib/connectors/utils';
import { connectorsConfiguration, createDefaultMapping } from '../connectors';
import { FieldMapping, FieldMappingProps } from './field_mapping';
import { mapping } from './__mock__';

View file

@ -14,16 +14,16 @@ import {
ActionType,
ThirdPartyField,
} from '../../containers/configure/types';
import { FieldMappingRow } from './field_mapping_row';
import * as i18n from './translations';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { setActionTypeToMapping, setThirdPartyToMapping } from './utils';
import {
ThirdPartyField as ConnectorConfigurationThirdPartyField,
AllThirdPartyFields,
} from '../../../common/lib/connectors/types';
import { createDefaultMapping } from '../../../common/lib/connectors/utils';
createDefaultMapping,
connectorsConfiguration,
} from '../connectors';
import { FieldMappingRow } from './field_mapping_row';
import * as i18n from './translations';
import { setActionTypeToMapping, setThirdPartyToMapping } from './utils';
const FieldRowWrapper = styled.div`
margin-top: 8px;

View file

@ -15,7 +15,7 @@ import {
import { capitalize } from 'lodash/fp';
import { CaseField, ActionType, ThirdPartyField } from '../../containers/configure/types';
import { AllThirdPartyFields } from '../../../common/lib/connectors/types';
import { AllThirdPartyFields } from '../connectors';
export interface RowProps {
id: string;

View file

@ -18,7 +18,7 @@ import { ClosureType } from '../../containers/configure/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { connectorsConfiguration } from '../connectors';
import { SectionWrapper } from '../wrappers';
import { Connectors } from './connectors';

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { UseField, Form, useForm, FormHook } from '../../../shared_imports';
import { ConnectorSelector } from './form';
import { connectorsMock } from '../../containers/mock';
import { getFormMock } from '../__mock__/form';
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
const useFormMock = useForm as jest.Mock;
describe('ConnectorSelector', () => {
const formHookMock = getFormMock({ connectorId: connectorsMock[0].id });
beforeEach(() => {
jest.resetAllMocks();
useFormMock.mockImplementation(() => ({ form: formHookMock }));
});
it('it should render', async () => {
const wrapper = mount(
<Form form={(formHookMock as unknown) as FormHook}>
<UseField
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors: connectorsMock,
dataTestSubj: 'caseConnectors',
disabled: false,
idAria: 'caseConnectors',
isLoading: false,
}}
/>
</Form>
);
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
});
it('it should not render when is not in edit mode', async () => {
const wrapper = mount(
<Form form={(formHookMock as unknown) as FormHook}>
<UseField
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors: connectorsMock,
dataTestSubj: 'caseConnectors',
disabled: false,
idAria: 'caseConnectors',
isLoading: false,
isEdit: false,
}}
/>
</Form>
);
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeFalsy();
});
});

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { isEmpty } from 'lodash/fp';
import { EuiFormRow } from '@elastic/eui';
import React, { useCallback, useEffect } from 'react';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports';
import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown';
@ -14,9 +15,8 @@ import { ActionConnector } from '../../../../../case/common/api/cases';
interface ConnectorSelectorProps {
connectors: ActionConnector[];
dataTestSubj: string;
defaultValue?: ActionConnector;
disabled: boolean;
field: FieldHook;
field: FieldHook<string>;
idAria: string;
isEdit: boolean;
isLoading: boolean;
@ -24,7 +24,6 @@ interface ConnectorSelectorProps {
export const ConnectorSelector = ({
connectors,
dataTestSubj,
defaultValue,
disabled = false,
field,
idAria,
@ -32,19 +31,6 @@ export const ConnectorSelector = ({
isLoading = false,
}: ConnectorSelectorProps) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
useEffect(() => {
field.setValue(defaultValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
const handleContentChange = useCallback(
(newConnector: string) => {
field.setValue(newConnector);
},
[field]
);
return isEdit ? (
<EuiFormRow
data-test-subj={dataTestSubj}
@ -60,8 +46,8 @@ export const ConnectorSelector = ({
connectors={connectors}
disabled={disabled}
isLoading={isLoading}
onChange={handleContentChange}
selectedConnector={(field.value as string) ?? 'none'}
onChange={field.setValue}
selectedConnector={isEmpty(field.value) ? 'none' : field.value}
/>
</EuiFormRow>
) : null;

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui';
import React, { memo, useMemo, useCallback } from 'react';
import { Case } from '../../../containers/types';
import * as i18n from './translations';
interface CaseDropdownProps {
isLoading: boolean;
cases: Case[];
selectedCase?: string;
onCaseChanged: (id: string) => void;
}
export const ADD_CASE_BUTTON_ID = 'add-case';
const addNewCase = {
value: ADD_CASE_BUTTON_ID,
inputDisplay: (
<span className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--flushLeft">
{i18n.CASE_CONNECTOR_ADD_NEW_CASE}
</span>
),
'data-test-subj': 'dropdown-connector-add-connector',
};
const CasesDropdownComponent: React.FC<CaseDropdownProps> = ({
isLoading,
cases,
selectedCase,
onCaseChanged,
}) => {
const caseOptions: Array<EuiSuperSelectOption<string>> = useMemo(
() =>
cases.reduce<Array<EuiSuperSelectOption<string>>>(
(acc, theCase) => [
...acc,
{
value: theCase.id,
inputDisplay: <span>{theCase.title}</span>,
'data-test-subj': `case-connector-cases-dropdown-${theCase.id}`,
},
],
[]
),
[cases]
);
const options = useMemo(() => [...caseOptions, addNewCase], [caseOptions]);
const onChange = useCallback((id: string) => onCaseChanged(id), [onCaseChanged]);
return (
<EuiFormRow label={i18n.CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL} fullWidth={true}>
<EuiSuperSelect
options={options}
data-test-subj="case-connector-cases-dropdown"
disabled={isLoading}
fullWidth
isLoading={isLoading}
valueOfSelected={selectedCase}
onChange={onChange}
/>
</EuiFormRow>
);
};
export const CasesDropdown = memo(CasesDropdownComponent);

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useMemo, useCallback } from 'react';
import { useGetCases } from '../../../containers/use_get_cases';
import { useCreateCaseModal } from '../../use_create_case_modal';
import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown';
interface ExistingCaseProps {
selectedCase: string | null;
onCaseChanged: (id: string) => void;
}
const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, selectedCase }) => {
const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases();
const onCaseCreated = useCallback(() => refetchCases(), [refetchCases]);
const { Modal: CreateCaseModal, openModal } = useCreateCaseModal({ onCaseCreated });
const onChange = useCallback(
(id: string) => {
if (id === ADD_CASE_BUTTON_ID) {
openModal();
return;
}
onCaseChanged(id);
},
[onCaseChanged, openModal]
);
const isCasesLoading = useMemo(
() => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'),
[isLoadingCases]
);
return (
<>
<CasesDropdown
isLoading={isCasesLoading}
cases={cases.cases}
selectedCase={selectedCase ?? undefined}
onCaseChanged={onChange}
/>
<CreateCaseModal />
</>
);
};
export const ExistingCase = memo(ExistingCaseComponent);

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { EuiCallOut } from '@elastic/eui';
import { ActionParamsProps } from '../../../../../../triggers_actions_ui/public/types';
import { CommentType } from '../../../../../../case/common/api';
import { CaseActionParams } from './types';
import { ExistingCase } from './existing_case';
import * as i18n from './translations';
const Container = styled.div`
${({ theme }) => `
margin-top: ${theme.eui?.euiSize ?? '16px'};
`}
`;
const defaultAlertComment = {
type: CommentType.alert,
alertId: '{{context.rule.id}}',
index: '{{context.rule.output_index}}',
};
const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionParams>> = ({
actionParams,
editAction,
index,
errors,
messageVariables,
actionConnector,
}) => {
const { caseId = null, comment = defaultAlertComment } = actionParams.subActionParams ?? {};
const [selectedCase, setSelectedCase] = useState<string | null>(null);
const editSubActionProperty = useCallback(
(key: string, value: unknown) => {
const newProps = { ...actionParams.subActionParams, [key]: value };
editAction('subActionParams', newProps, index);
},
[actionParams.subActionParams, editAction, index]
);
const onCaseChanged = useCallback(
(id: string) => {
setSelectedCase(id);
editSubActionProperty('caseId', id);
},
[editSubActionProperty]
);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'addComment', index);
}
if (!actionParams.subActionParams?.caseId) {
editSubActionProperty('caseId', caseId);
}
if (!actionParams.subActionParams?.comment) {
editSubActionProperty('comment', comment);
}
if (caseId != null) {
setSelectedCase((prevCaseId) => (prevCaseId !== caseId ? caseId : prevCaseId));
}
// editAction creates an infinity loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
actionConnector,
index,
actionParams.subActionParams?.caseId,
actionParams.subActionParams?.comment,
caseId,
comment,
actionParams.subAction,
]);
return (
<>
<EuiCallOut size="s" title={i18n.CASE_CONNECTOR_CALL_OUT_INFO} iconType="iInCircle" />
<Container>
<ExistingCase onCaseChanged={onCaseChanged} selectedCase={selectedCase} />
</Container>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { CaseParamsFields as default };

View file

@ -3,11 +3,29 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { lazy } from 'react';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionTypeModel } from '../../../../../../triggers_actions_ui/public/types';
import { CaseActionParams } from './types';
import * as i18n from './translations';
interface ValidationResult {
errors: {
caseId: string[];
};
}
const validateParams = (actionParams: CaseActionParams) => {
const validationResult: ValidationResult = { errors: { caseId: [] } };
if (actionParams.subActionParams && !actionParams.subActionParams.caseId) {
validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED);
}
return validationResult;
};
export function getActionType(): ActionTypeModel {
return {
id: '.case',
@ -15,8 +33,8 @@ export function getActionType(): ActionTypeModel {
selectMessage: i18n.CASE_CONNECTOR_DESC,
actionTypeTitle: i18n.CASE_CONNECTOR_TITLE,
validateConnector: () => ({ errors: {} }),
validateParams: () => ({ errors: {} }),
validateParams,
actionConnectorFields: null,
actionParamsFields: null,
actionParamsFields: lazy(() => import('./fields')),
};
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export * from '../../../translations';
export const CASE_CONNECTOR_DESC = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.selectMessageText',
{
defaultMessage: 'Create or update a case.',
}
);
export const CASE_CONNECTOR_TITLE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.actionTypeTitle',
{
defaultMessage: 'Cases',
}
);
export const CASE_CONNECTOR_COMMENT_LABEL = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.commentLabel',
{
defaultMessage: 'Comment',
}
);
export const CASE_CONNECTOR_COMMENT_REQUIRED = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.commentRequired',
{
defaultMessage: 'Comment is required.',
}
);
export const CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel',
{
defaultMessage: 'Case',
}
);
export const CASE_CONNECTOR_CASES_DROPDOWN_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.casesDropdownPlaceholder',
{
defaultMessage: 'Select case',
}
);
export const CASE_CONNECTOR_CASES_OPTION_NEW_CASE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.optionAddNewCase',
{
defaultMessage: 'Add to a new case',
}
);
export const CASE_CONNECTOR_CASES_OPTION_EXISTING_CASE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.optionAddToExistingCase',
{
defaultMessage: 'Add to existing case',
}
);
export const CASE_CONNECTOR_CASE_REQUIRED = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.caseRequired',
{
defaultMessage: 'You must select a case.',
}
);
export const CASE_CONNECTOR_CALL_OUT_INFO = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.callOutInfo',
{
defaultMessage: 'All alerts after rule creation will be appended to the selected case.',
}
);
export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.addNewCaseOption',
{
defaultMessage: 'Add new case',
}
);

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface CaseActionParams {
subAction: string;
subActionParams: {
caseId: string;
comment: {
alertId: string;
index: string;
type: 'alert';
};
};
}

View file

@ -5,3 +5,7 @@
*/
export { getActionType as getCaseConnectorUI } from './case';
export * from './config';
export * from './types';
export * from './utils';

View file

@ -0,0 +1,186 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { useForm, Form, FormHook } from '../../../shared_imports';
import { connectorsMock } from '../../containers/mock';
import { Connector } from './connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types';
import { useGetSeverity } from '../settings/resilient/use_get_severity';
jest.mock('../../../common/lib/kibana', () => {
return {
useKibana: () => ({
services: {
notifications: {},
http: {},
},
}),
};
});
jest.mock('../../containers/configure/use_connectors');
jest.mock('../settings/resilient/use_get_incident_types');
jest.mock('../settings/resilient/use_get_severity');
const useConnectorsMock = useConnectors as jest.Mock;
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const useGetIncidentTypesResponse = {
isLoading: false,
incidentTypes: [
{
id: 19,
name: 'Malware',
},
{
id: 21,
name: 'Denial of Service',
},
],
};
const useGetSeverityResponse = {
isLoading: false,
severity: [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
],
};
describe('Connector', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<{ connectorId: string; fields: Record<string, unknown> | null }>({
defaultValue: { connectorId: connectorsMock[0].id, fields: null },
});
globalForm = form;
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector isLoading={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="connector-settings"]`).exists()).toBeTruthy();
waitFor(() => {
expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy();
});
});
it('it is loading when fetching connectors', async () => {
useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock });
const wrapper = mount(
<MockHookWrapperComponent>
<Connector isLoading={false} />
</MockHookWrapperComponent>
);
expect(
wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('isLoading')
).toEqual(true);
});
it('it is disabled when fetching connectors', async () => {
useConnectorsMock.mockReturnValue({ loading: true, connectors: connectorsMock });
const wrapper = mount(
<MockHookWrapperComponent>
<Connector isLoading={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find('[data-test-subj="dropdown-connectors"]').first().prop('disabled')).toEqual(
true
);
});
it('it is disabled and loading when passing loading as true', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector isLoading={true} />
</MockHookWrapperComponent>
);
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(
<MockHookWrapperComponent>
<Connector isLoading={false} />
</MockHookWrapperComponent>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="connector-settings-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');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-settings-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' },
});
});
});
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ConnectorTypeFields } from '../../../../../case/common/api/connectors';
import { UseField, useFormData, FieldHook } from '../../../shared_imports';
import { useConnectors } from '../../containers/configure/use_connectors';
import { ConnectorSelector } from '../connector_selector/form';
import { SettingFieldsForm } from '../settings/fields_form';
import { ActionConnector } from '../../containers/types';
import { getConnectorById } from '../configure_cases/utils';
interface Props {
isLoading: boolean;
}
interface SettingsFieldProps {
connectors: ActionConnector[];
field: FieldHook<ConnectorTypeFields['fields']>;
isEdit: boolean;
}
const SettingsField = ({ connectors, isEdit, field }: SettingsFieldProps) => {
const [{ connectorId }] = useFormData({ watch: ['connectorId'] });
const { setValue } = field;
const connector = getConnectorById(connectorId, connectors) ?? null;
useEffect(() => {
if (connectorId) {
setValue(null);
}
}, [setValue, connectorId]);
return (
<SettingFieldsForm
connector={connector}
fields={field.value}
isEdit={isEdit}
onChange={setValue}
/>
);
};
const ConnectorComponent: React.FC<Props> = ({ isLoading }) => {
const { loading: isLoadingConnectors, connectors } = useConnectors();
return (
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
disabled: isLoading || isLoadingConnectors,
idAria: 'caseConnectors',
isLoading: isLoading || isLoadingConnectors,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="fields"
component={SettingsField}
componentProps={{
connectors,
isEdit: true,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
ConnectorComponent.displayName = 'ConnectorComponent';
export const Connector = memo(ConnectorComponent);

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from '@testing-library/react';
import { useForm, Form, FormHook } from '../../../shared_imports';
import { Description } from './description';
describe('Description', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<{ description: string }>({
defaultValue: { description: 'My description' },
});
globalForm = form;
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Description isLoading={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy();
});
it('it changes the description', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Description isLoading={true} />
</MockHookWrapperComponent>
);
await act(async () => {
wrapper
.find(`[data-test-subj="caseDescription"] textarea`)
.first()
.simulate('change', { target: { value: 'My new description' } });
});
expect(globalForm.getFormData()).toEqual({ description: 'My new description' });
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { UseField } from '../../../shared_imports';
interface Props {
isLoading: boolean;
}
export const fieldName = 'description';
const DescriptionComponent: React.FC<Props> = ({ isLoading }) => (
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
}}
/>
);
DescriptionComponent.displayName = 'DescriptionComponent';
export const Description = memo(DescriptionComponent);

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { useForm, Form } from '../../../shared_imports';
import { useGetTags } from '../../containers/use_get_tags';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/mock';
import { schema, FormProps } from './schema';
import { CreateCaseForm } from './form';
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
const useGetTagsMock = useGetTags as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const initialCaseValue: FormProps = {
description: '',
tags: [],
title: '',
connectorId: 'none',
fields: null,
};
describe('CreateCaseForm', () => {
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
});
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
useGetTagsMock.mockReturnValue({ tags: ['test'] });
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
});
it('it renders with steps', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeTruthy();
});
it('it renders without steps', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm withSteps={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy();
});
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { EuiLoadingSpinner, EuiSteps } from '@elastic/eui';
import styled, { css } from 'styled-components';
import { useFormContext } from '../../../shared_imports';
import { Title } from './title';
import { Description } from './description';
import { Tags } from './tags';
import { Connector } from './connector';
import * as i18n from './translations';
interface ContainerProps {
big?: boolean;
}
const Container = styled.div.attrs((props) => props)<ContainerProps>`
${({ big, theme }) => css`
margin-top: ${big ? theme.eui?.euiSizeXL ?? '32px' : theme.eui?.euiSize ?? '16px'};
`}
`;
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
top: 50%;
left: 50%;
z-index: 99;
`;
interface Props {
withSteps?: boolean;
}
export const CreateCaseForm: React.FC<Props> = React.memo(({ withSteps = true }) => {
const { isSubmitting } = useFormContext();
const firstStep = useMemo(
() => ({
title: i18n.STEP_ONE_TITLE,
children: (
<>
<Title isLoading={isSubmitting} />
<Container>
<Tags isLoading={isSubmitting} />
</Container>
<Container big>
<Description isLoading={isSubmitting} />
</Container>
</>
),
}),
[isSubmitting]
);
const secondStep = useMemo(
() => ({
title: i18n.STEP_TWO_TITLE,
children: (
<Container>
<Connector isLoading={isSubmitting} />
</Container>
),
}),
[isSubmitting]
);
const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]);
return (
<>
{isSubmitting && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />}
{withSteps ? (
<EuiSteps
headingElement="h2"
steps={allSteps}
data-test-subj={'case-creation-form-steps'}
/>
) : (
<>
{firstStep.children}
{secondStep.children}
</>
)}
</>
);
});
CreateCaseForm.displayName = 'CreateCaseForm';

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect } from 'react';
import { schema, FormProps } from './schema';
import { Form, useForm } from '../../../shared_imports';
import {
getConnectorById,
getNoneConnector,
normalizeActionConnector,
} from '../configure_cases/utils';
import { usePostCase } from '../../containers/use_post_case';
import { useConnectors } from '../../containers/configure/use_connectors';
import { Case } from '../../containers/types';
const initialCaseValue: FormProps = {
description: '',
tags: [],
title: '',
connectorId: 'none',
fields: null,
};
interface Props {
onSuccess?: (theCase: Case) => void;
}
export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
const { connectors } = useConnectors();
const { caseData, postCase } = usePostCase();
const submitCase = useCallback(
async ({ connectorId: dataConnectorId, fields, ...dataWithoutConnectorId }, isValid) => {
if (isValid) {
const caseConnector = getConnectorById(dataConnectorId, connectors);
const connectorToUpdate = caseConnector
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate });
}
},
[postCase, connectors]
);
const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
onSubmit: submitCase,
});
useEffect(() => {
if (caseData && onSuccess) {
onSuccess(caseData);
}
}, [caseData, onSuccess]);
return <Form form={form}>{children}</Form>;
};
FormContext.displayName = 'FormContext';

View file

@ -5,71 +5,40 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { Create } from '.';
import { mount, ReactWrapper } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { TestProviders } from '../../../common/mock';
import { getFormMock } from '../__mock__/form';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { usePostCase } from '../../containers/use_post_case';
import { useGetTags } from '../../containers/use_get_tags';
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
import { waitFor } from '@testing-library/react';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types';
import { useGetSeverity } from '../settings/resilient/use_get_severity';
import { useGetIssueTypes } from '../settings/jira/use_get_issue_types';
import { useGetFieldsByIssueType } from '../settings/jira/use_get_fields_by_issue_type';
import { Create } from '.';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
// eslint-disable-next-line react/display-name
EuiFieldText: () => <input />,
};
});
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_post_case');
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider',
() => ({
FormDataProvider: ({ children }: { children: ({ tags }: { tags: string[] }) => void }) =>
children({ tags: ['rad', 'dude'] }),
})
);
jest.mock('../settings/resilient/use_get_incident_types');
jest.mock('../settings/resilient/use_get_severity');
jest.mock('../settings/jira/use_get_issue_types');
jest.mock('../settings/jira/use_get_fields_by_issue_type');
jest.mock('../settings/jira/use_get_single_issue');
jest.mock('../settings/jira/use_get_issues');
const useConnectorsMock = useConnectors as jest.Mock;
const useFormMock = useForm as jest.Mock;
const useFormDataMock = useFormData as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const usePostCaseMock = usePostCase as jest.Mock;
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const useGetIssueTypesMock = useGetIssueTypes as jest.Mock;
const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock;
const postCase = jest.fn();
const handleCursorChange = jest.fn();
const handleOnTimelineChange = jest.fn();
const defaultInsertTimeline = {
cursorPosition: {
start: 0,
end: 0,
},
handleCursorChange,
handleOnTimelineChange,
};
const sampleTags = ['coke', 'pepsi'];
const sampleData = {
@ -83,27 +52,117 @@ const sampleData = {
type: ConnectorTypes.none,
},
};
const defaultPostCase = {
isLoading: false,
isError: false,
caseData: null,
postCase,
};
const sampleConnectorData = { loading: false, connectors: [] };
const useGetIncidentTypesResponse = {
isLoading: false,
incidentTypes: [
{
id: 19,
name: 'Malware',
},
{
id: 21,
name: 'Denial of Service',
},
],
};
const useGetSeverityResponse = {
isLoading: false,
severity: [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
],
};
const useGetIssueTypesResponse = {
isLoading: false,
issueTypes: [
{
id: '10006',
name: 'Task',
},
{
id: '10007',
name: 'Bug',
},
],
};
const useGetFieldsByIssueTypeResponse = {
isLoading: false,
fields: {
summary: { allowedValues: [], defaultValue: {} },
labels: { allowedValues: [], defaultValue: {} },
description: { allowedValues: [], defaultValue: {} },
priority: {
allowedValues: [
{
name: 'Medium',
id: '3',
},
{
name: 'Low',
id: '2',
},
],
defaultValue: { name: 'Medium', id: '3' },
},
},
};
const fillForm = async (wrapper: ReactWrapper) => {
await act(async () => {
wrapper
.find(`[data-test-subj="caseTitle"] input`)
.first()
.simulate('change', { target: { value: sampleData.title } });
});
await act(async () => {
wrapper
.find(`[data-test-subj="caseDescription"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.description } });
});
await waitFor(() => {
((wrapper.find(EuiComboBox).props() as unknown) as {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange(sampleTags.map((tag) => ({ label: tag })));
});
};
describe('Create case', () => {
const fetchTags = jest.fn();
const formHookMock = getFormMock(sampleData);
beforeEach(() => {
jest.resetAllMocks();
useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline);
usePostCaseMock.mockImplementation(() => defaultPostCase);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [
{
description: sampleData.description,
},
]);
useConnectorsMock.mockReturnValue(sampleConnectorData);
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse);
useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
(useGetTags as jest.Mock).mockImplementation(() => ({
tags: sampleTags,
@ -112,7 +171,7 @@ describe('Create case', () => {
});
describe('Step 1 - Case Fields', () => {
it('should post case on submit click', async () => {
it('it renders', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -120,7 +179,38 @@ describe('Create case', () => {
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
expect(wrapper.find(`[data-test-subj="caseTitle"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseDescription"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseTags"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="create-case-submit"]`).first().exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).first().exists()).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="case-creation-form-steps"]`).first().exists()
).toBeTruthy();
});
it('should post case on submit click', async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await fillForm(wrapper);
wrapper.update();
await act(async () => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
await waitFor(() => expect(postCase).toBeCalledWith(sampleData));
});
@ -132,15 +222,18 @@ describe('Create case', () => {
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click');
await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/'));
});
it('should redirect to new case when caseData is there', async () => {
const sampleId = '777777';
const sampleId = 'case-id';
usePostCaseMock.mockImplementation(() => ({
...defaultPostCase,
caseData: { id: sampleId },
}));
mount(
<TestProviders>
<Router history={mockHistory}>
@ -148,11 +241,11 @@ describe('Create case', () => {
</Router>
</TestProviders>
);
await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/777777'));
await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/case-id'));
});
it('should render spinner when loading', async () => {
usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true }));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -160,75 +253,197 @@ describe('Create case', () => {
</Router>
</TestProviders>
);
await waitFor(() =>
expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy()
);
});
it('Tag options render with new tags added', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() =>
await fillForm(wrapper);
await act(async () => {
await wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
wrapper.update();
expect(
wrapper
.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`)
.first()
.prop('options')
).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }])
);
});
});
// FAILED ES PROMOTION: https://github.com/elastic/kibana/issues/84145
describe.skip('Step 2 - Connector Fields', () => {
const connectorTypes = [
{
label: 'Jira',
testId: 'jira-1',
dataTestSubj: 'connector-settings-jira',
},
{
label: 'Resilient',
testId: 'resilient-2',
dataTestSubj: 'connector-settings-resilient',
},
{
label: 'ServiceNow',
testId: 'servicenow-1',
dataTestSubj: 'connector-settings-sn',
},
];
connectorTypes.forEach(({ label, testId, dataTestSubj }) => {
it(`should change from none to ${label} connector fields`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-${testId}"]`).simulate('click');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeTruthy();
});
wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()
).toBeTruthy();
});
});
});
describe('Step 2 - Connector Fields', () => {
it(`it should submit a Jira connector`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await fillForm(wrapper);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeTruthy();
});
act(() => {
wrapper
.find('select[data-test-subj="issueTypeSelect"]')
.first()
.simulate('change', {
target: { value: '10007' },
});
});
act(() => {
wrapper
.find('select[data-test-subj="prioritySelect"]')
.first()
.simulate('change', {
target: { value: '2' },
});
});
await act(async () => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
await waitFor(() =>
expect(postCase).toBeCalledWith({
...sampleData,
connector: {
id: 'jira-1',
name: 'Jira',
type: '.jira',
fields: { issueType: '10007', parent: null, priority: '2' },
},
})
);
});
it(`it should submit a resilient connector`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await fillForm(wrapper);
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="connector-settings-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');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(
wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()
).toBeTruthy();
});
act(() => {
((wrapper.find(EuiComboBox).at(1).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 act(async () => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
await waitFor(() =>
expect(postCase).toBeCalledWith({
...sampleData,
connector: {
id: 'resilient-2',
name: 'My Connector 2',
type: '.resilient',
fields: { incidentTypes: ['19'], severityCode: '4' },
},
})
);
});
it(`it should submit a servicenow connector`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await fillForm(wrapper);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy();
});
['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => {
act(() => {
wrapper
.find(`select[data-test-subj="${subj}"]`)
.first()
.simulate('change', {
target: { value: '2' },
});
});
});
await act(async () => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
await waitFor(() =>
expect(postCase).toBeCalledWith({
...sampleData,
connector: {
id: 'servicenow-1',
name: 'My Connector',
type: '.servicenow',
fields: { impact: '2', severity: '2', urgency: '2' },
},
})
);
});
});
});

View file

@ -3,319 +3,81 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
EuiSteps,
} from '@elastic/eui';
import styled, { css } from 'styled-components';
import { useHistory } from 'react-router-dom';
import { isEqual } from 'lodash/fp';
import {
Field,
Form,
FormDataProvider,
getUseField,
UseField,
useForm,
useFormData,
} from '../../../shared_imports';
import { usePostCase } from '../../containers/use_post_case';
import { schema, FormProps } from './schema';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { useGetTags } from '../../containers/use_get_tags';
import React, { useCallback } from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
import { useHistory } from 'react-router-dom';
import { Field, getUseField, useFormContext } from '../../../shared_imports';
import { getCaseDetailsUrl } from '../../../common/components/link_to';
import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click';
import { SettingFieldsForm } from '../settings/fields_form';
import { useConnectors } from '../../containers/configure/use_connectors';
import { ConnectorSelector } from '../connector_selector/form';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import {
normalizeCaseConnector,
getConnectorById,
getNoneConnector,
normalizeActionConnector,
} from '../configure_cases/utils';
import { ActionConnector } from '../../containers/types';
import { ConnectorFields } from '../../../../../case/common/api/connectors';
import * as i18n from './translations';
import { CreateCaseForm } from './form';
import { FormContext } from './form_context';
import { useInsertTimeline } from '../use_insert_timeline';
import { fieldName as descriptionFieldName } from './description';
import { SubmitCaseButton } from './submit_button';
export const CommonUseField = getUseField({ component: Field });
interface ContainerProps {
big?: boolean;
}
const Container = styled.div.attrs((props) => props)<ContainerProps>`
${({ big, theme }) => css`
margin-top: ${big ? theme.eui.euiSizeXL : theme.eui.euiSize};
const Container = styled.div`
${({ theme }) => `
margin-top: ${theme.eui.euiSize};
`}
`;
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
top: 50%;
left: 50%;
z-index: 99;
`;
const initialCaseValue: FormProps = {
description: '',
tags: [],
title: '',
connectorId: 'none',
const InsertTimeline = () => {
const { setFieldValue, getFormData } = useFormContext();
const formData = getFormData();
const onTimelineAttached = useCallback(
(newValue: string) => setFieldValue(descriptionFieldName, newValue),
[setFieldValue]
);
useInsertTimeline(formData[descriptionFieldName] ?? '', onTimelineAttached);
return null;
};
export const Create = React.memo(() => {
const history = useHistory();
const { caseData, isLoading, postCase } = usePostCase();
const { loading: isLoadingConnectors, connectors } = useConnectors();
const { connector: configureConnector, loading: isLoadingCaseConfigure } = useCaseConfigure();
const { tags: tagOptions } = useGetTags();
const [connector, setConnector] = useState<ActionConnector | null>(null);
const [options, setOptions] = useState(
tagOptions.map((label) => ({
label,
}))
);
// This values uses useEffect to update, not useMemo,
// because we need to setState on it from the jsx
useEffect(
() =>
setOptions(
tagOptions.map((label) => ({
label,
}))
),
[tagOptions]
);
const [fields, setFields] = useState<ConnectorFields>(null);
const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
});
const currentConnectorId = useMemo(
() =>
!isLoadingCaseConfigure
? normalizeCaseConnector(connectors, configureConnector)?.id ?? 'none'
: null,
[configureConnector, connectors, isLoadingCaseConfigure]
);
const { submit, setFieldValue } = form;
const [{ description }] = useFormData<{
description: string;
}>({
form,
watch: ['description'],
});
const onChangeConnector = useCallback(
(newConnectorId) => {
if (connector == null || connector.id !== newConnectorId) {
setConnector(getConnectorById(newConnectorId, connectors) ?? null);
// Reset setting fields when changing connector
setFields(null);
}
const onSuccess = useCallback(
({ id }) => {
history.push(getCaseDetailsUrl({ id }));
},
[connector, connectors]
[history]
);
const onDescriptionChange = useCallback((newValue) => setFieldValue('description', newValue), [
setFieldValue,
]);
const { handleCursorChange } = useInsertTimeline(description, onDescriptionChange);
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
const { connectorId: dataConnectorId, ...dataWithoutConnectorId } = data;
const caseConnector = getConnectorById(dataConnectorId, connectors);
const connectorToUpdate = caseConnector
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate });
}
}, [submit, postCase, fields, connectors]);
const handleSetIsCancel = useCallback(() => {
history.push('/');
}, [history]);
const firstStep = useMemo(
() => ({
title: i18n.STEP_ONE_TITLE,
children: (
<>
<CommonUseField
path="title"
componentProps={{
idAria: 'caseTitle',
'data-test-subj': 'caseTitle',
euiFieldProps: {
fullWidth: false,
disabled: isLoading,
},
}}
/>
<Container>
<CommonUseField
path="tags"
componentProps={{
idAria: 'caseTags',
'data-test-subj': 'caseTags',
euiFieldProps: {
fullWidth: true,
placeholder: '',
disabled: isLoading,
options,
noSuggestions: false,
},
}}
/>
<FormDataProvider pathsToWatch="tags">
{({ tags: anotherTags }) => {
const current: string[] = options.map((opt) => opt.label);
const newOptions = anotherTags.reduce((acc: string[], item: string) => {
if (!acc.includes(item)) {
return [...acc, item];
}
return acc;
}, current);
if (!isEqual(current, newOptions)) {
setOptions(
newOptions.map((label: string) => ({
label,
}))
);
}
return null;
}}
</FormDataProvider>
</Container>
<Container big>
<UseField
path={'description'}
component={MarkdownEditorForm}
componentProps={{
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
onClickTimeline: handleTimelineClick,
onCursorPositionUpdate: handleCursorChange,
}}
/>
</Container>
</>
),
}),
[isLoading, options, handleCursorChange, handleTimelineClick]
);
const secondStep = useMemo(
() => ({
title: i18n.STEP_TWO_TITLE,
children: (
<EuiFlexGroup>
<EuiFlexItem>
<Container>
<UseField
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
defaultValue: currentConnectorId,
disabled: isLoadingConnectors,
idAria: 'caseConnectors',
isLoading,
}}
onChange={onChangeConnector}
/>
</Container>
</EuiFlexItem>
<EuiFlexItem>
<Container>
<SettingFieldsForm
connector={connector}
fields={fields}
isEdit={true}
onChange={setFields}
/>
</Container>
</EuiFlexItem>
</EuiFlexGroup>
),
}),
[
connector,
connectors,
currentConnectorId,
fields,
isLoading,
isLoadingConnectors,
onChangeConnector,
]
);
const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]);
if (caseData != null && caseData.id) {
history.push(getCaseDetailsUrl({ id: caseData.id }));
return null;
}
return (
<EuiPanel>
{isLoading && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />}
<Form form={form}>
<EuiSteps headingElement="h2" steps={allSteps} />
</Form>
<Container>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="create-case-cancel"
size="s"
onClick={handleSetIsCancel}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="create-case-submit"
fill
iconType="plusInCircle"
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit}
>
{i18n.CREATE_CASE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Container>
<FormContext onSuccess={onSuccess}>
<CreateCaseForm />
<Container>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="create-case-cancel"
size="s"
onClick={handleSetIsCancel}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SubmitCaseButton />
</EuiFlexItem>
</EuiFlexGroup>
</Container>
<InsertTimeline />
</FormContext>
</EuiPanel>
);
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import { OptionalFieldLabel } from '.';
describe('OptionalFieldLabel', () => {
it('it renders correctly', async () => {
const wrapper = mount(OptionalFieldLabel);
expect(wrapper.find('[data-test-subj="form-optional-field-label"]').first().text()).toBe(
'Optional'
);
});
});

View file

@ -10,7 +10,7 @@ import React from 'react';
import * as i18n from '../../../translations';
export const OptionalFieldLabel = (
<EuiText color="subdued" size="xs">
<EuiText color="subdued" size="xs" data-test-subj="form-optional-field-label">
{i18n.OPTIONAL}
</EuiText>
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CasePostRequest } from '../../../../../case/common/api';
import { CasePostRequest, ConnectorTypeFields } from '../../../../../case/common/api';
import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports';
import * as i18n from '../../translations';
@ -18,7 +18,10 @@ export const schemaTags = {
labelAppend: OptionalFieldLabel,
};
export type FormProps = Omit<CasePostRequest, 'connector'> & { connectorId: string };
export type FormProps = Omit<CasePostRequest, 'connector'> & {
connectorId: string;
fields: ConnectorTypeFields['fields'];
};
export const schema: FormSchema<FormProps> = {
title: {

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { useForm, Form } from '../../../shared_imports';
import { SubmitCaseButton } from './submit_button';
describe('SubmitCaseButton', () => {
const onSubmit = jest.fn();
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<{ title: string }>({
defaultValue: { title: 'My title' },
onSubmit,
});
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<SubmitCaseButton />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy();
});
it('it submits', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<SubmitCaseButton />
</MockHookWrapperComponent>
);
await act(async () => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
});
await waitFor(() => expect(onSubmit).toBeCalled());
});
it('it disables when submitting', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<SubmitCaseButton />
</MockHookWrapperComponent>
);
await waitFor(() => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
expect(
wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isDisabled')
).toBeTruthy();
});
});
it('it is loading when submitting', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<SubmitCaseButton />
</MockHookWrapperComponent>
);
await waitFor(() => {
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
expect(
wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isLoading')
).toBeTruthy();
});
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import { EuiButton } from '@elastic/eui';
import { useFormContext } from '../../../shared_imports';
import * as i18n from './translations';
const SubmitCaseButtonComponent: React.FC = () => {
const { submit, isSubmitting } = useFormContext();
return (
<EuiButton
data-test-subj="create-case-submit"
fill
iconType="plusInCircle"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={submit}
>
{i18n.CREATE_CASE}
</EuiButton>
);
};
export const SubmitCaseButton = memo(SubmitCaseButtonComponent);

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { waitFor } from '@testing-library/react';
import { useForm, Form, FormHook, FIELD_TYPES } from '../../../shared_imports';
import { useGetTags } from '../../containers/use_get_tags';
import { Tags } from './tags';
jest.mock('../../containers/use_get_tags');
const useGetTagsMock = useGetTags as jest.Mock;
describe('Tags', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<{ tags: string[] }>({
defaultValue: { tags: [] },
schema: {
tags: { type: FIELD_TYPES.COMBO_BOX },
},
});
globalForm = form;
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
useGetTagsMock.mockReturnValue({ tags: ['test'] });
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Tags isLoading={false} />
</MockHookWrapperComponent>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy();
});
});
it('it disables the input when loading', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Tags isLoading={true} />
</MockHookWrapperComponent>
);
expect(wrapper.find(EuiComboBox).prop('disabled')).toBeTruthy();
});
it('it changes the tags', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Tags isLoading={false} />
</MockHookWrapperComponent>
);
await waitFor(() => {
((wrapper.find(EuiComboBox).props() as unknown) as {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange(['test', 'case'].map((tag) => ({ label: tag })));
});
expect(globalForm.getFormData()).toEqual({ tags: ['test', 'case'] });
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useMemo } from 'react';
import { Field, getUseField } from '../../../shared_imports';
import { useGetTags } from '../../containers/use_get_tags';
const CommonUseField = getUseField({ component: Field });
interface Props {
isLoading: boolean;
}
const TagsComponent: React.FC<Props> = ({ isLoading }) => {
const { tags: tagOptions, isLoading: isLoadingTags } = useGetTags();
const options = useMemo(
() =>
tagOptions.map((label) => ({
label,
})),
[tagOptions]
);
return (
<CommonUseField
path="tags"
componentProps={{
idAria: 'caseTags',
'data-test-subj': 'caseTags',
euiFieldProps: {
fullWidth: true,
placeholder: '',
disabled: isLoading || isLoadingTags,
options,
noSuggestions: false,
},
}}
/>
);
};
TagsComponent.displayName = 'TagsComponent';
export const Tags = memo(TagsComponent);

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from '@testing-library/react';
import { useForm, Form, FormHook } from '../../../shared_imports';
import { Title } from './title';
describe('Title', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<{ title: string }>({
defaultValue: { title: 'My title' },
});
globalForm = form;
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.resetAllMocks();
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Title isLoading={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy();
});
it('it disables the input when loading', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Title isLoading={true} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseTitle"] input`).prop('disabled')).toBeTruthy();
});
it('it changes the title', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Title isLoading={false} />
</MockHookWrapperComponent>
);
await act(async () => {
wrapper
.find(`[data-test-subj="caseTitle"] input`)
.first()
.simulate('change', { target: { value: 'My new title' } });
});
expect(globalForm.getFormData()).toEqual({ title: 'My new title' });
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import { Field, getUseField } from '../../../shared_imports';
const CommonUseField = getUseField({ component: Field });
interface Props {
isLoading: boolean;
}
const TitleComponent: React.FC<Props> = ({ isLoading }) => (
<CommonUseField
path="title"
componentProps={{
idAria: 'caseTitle',
'data-test-subj': 'caseTitle',
euiFieldProps: {
fullWidth: true,
disabled: isLoading,
},
}}
/>
);
TitleComponent.displayName = 'TitleComponent';
export const Title = memo(TitleComponent);

View file

@ -8,7 +8,7 @@ import React, { memo, useMemo } from 'react';
import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { connectorsConfiguration } from '../connectors';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
interface ConnectorCardProps {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, Suspense, useCallback } from 'react';
import React, { memo, Suspense } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { CaseSettingsConnector, SettingFieldsProps } from './types';
@ -18,13 +18,6 @@ interface Props extends Omit<SettingFieldsProps<ConnectorTypeFields['fields']>,
const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChange, fields }) => {
const { caseSettingsRegistry } = getCaseSettings();
const onFieldsChange = useCallback(
(newFields) => {
onChange(newFields);
},
[onChange]
);
if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') {
return null;
}
@ -45,12 +38,14 @@ const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChan
</EuiFlexGroup>
}
>
<FieldsComponent
isEdit={isEdit}
fields={fields}
connector={connector}
onChange={onFieldsChange}
/>
<div data-test-subj={'connector-settings'}>
<FieldsComponent
isEdit={isEdit}
fields={fields}
connector={connector}
onChange={onChange}
/>
</div>
</Suspense>
) : null}
</>

View file

@ -18,13 +18,14 @@ import {
import styled, { css } from 'styled-components';
import { isEqual } from 'lodash/fp';
import * as i18n from './translations';
import { Form, FormDataProvider, useForm } from '../../../shared_imports';
import { Form, FormDataProvider, useForm, getUseField, Field } from '../../../shared_imports';
import { schema } from './schema';
import { CommonUseField } from '../create';
import { useGetTags } from '../../containers/use_get_tags';
import { Tags } from './tags';
const CommonUseField = getUseField({ component: Field });
interface TagListProps {
disabled?: boolean;
isLoading: boolean;

View file

@ -22,10 +22,7 @@ export interface AllCasesModalProps {
onRowClick: (id?: string) => void;
}
const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({
onCloseCaseModal,
onRowClick,
}: AllCasesModalProps) => {
const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({ onCloseCaseModal, onRowClick }) => {
const userPermissions = useGetUserSavedObjectPermissions();
const userCanCrud = userPermissions?.crud ?? false;
return (

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useCallback } from 'react';
import styled from 'styled-components';
import {
EuiModal,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
} from '@elastic/eui';
import { FormContext } from '../create/form_context';
import { CreateCaseForm } from '../create/form';
import { SubmitCaseButton } from '../create/submit_button';
import { Case } from '../../containers/types';
import * as i18n from '../../translations';
export interface CreateCaseModalProps {
onCloseCaseModal: () => void;
onCaseCreated: (theCase: Case) => void;
}
const Container = styled.div`
${({ theme }) => `
margin-top: ${theme.eui.euiSize};
text-align: right;
`}
`;
const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
onCloseCaseModal,
onCaseCreated,
}) => {
const onSuccess = useCallback(
(theCase) => {
onCaseCreated(theCase);
onCloseCaseModal();
},
[onCaseCreated, onCloseCaseModal]
);
return (
<EuiOverlayMask data-test-subj="all-cases-modal">
<EuiModal onClose={onCloseCaseModal}>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormContext onSuccess={onSuccess}>
<CreateCaseForm withSteps={false} />
<Container>
<SubmitCaseButton />
</Container>
</FormContext>
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
};
export const CreateCaseModal = memo(CreateModalComponent);
CreateCaseModal.displayName = 'CreateCaseModal';

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { Case } from '../../containers/types';
import { CreateCaseModal } from './create_case_modal';
interface Props {
onCaseCreated: (theCase: Case) => void;
}
export interface UseAllCasesModalReturnedValues {
Modal: React.FC;
isModalOpen: boolean;
closeModal: () => void;
openModal: () => void;
}
export const useCreateCaseModal = ({ onCaseCreated }: Props) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
const Modal: React.FC = useCallback(
() =>
isModalOpen ? (
<CreateCaseModal onCloseCaseModal={closeModal} onCaseCreated={onCaseCreated} />
) : null,
[closeModal, isModalOpen, onCaseCreated]
);
const state = useMemo(
() => ({
Modal,
isModalOpen,
closeModal,
openModal,
}),
[isModalOpen, closeModal, openModal, Modal]
);
return state;
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { isEmpty } from 'lodash/fp';
import { getTimelineUrl, useFormatUrl } from '../../../common/components/link_to';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline';
import { SecurityPageName } from '../../../app/types';
import { setInsertTimeline } from '../../../timelines/store/timeline/actions';
interface UseInsertTimelineReturn {
handleOnTimelineChange: (title: string, id: string | null, graphEventId?: string) => void;
}
export const useInsertTimeline = (
value: string,
onChange: (newValue: string) => void
): UseInsertTimelineReturn => {
const dispatch = useDispatch();
const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
const insertTimeline = useShallowEqualSelector(timelineSelectors.selectInsertTimeline);
const handleOnTimelineChange = useCallback(
(title: string, id: string | null, graphEventId?: string) => {
const url = formatUrl(getTimelineUrl(id ?? '', graphEventId), {
absolute: true,
skipSearch: true,
});
let newValue = `[${title}](${url})`;
// Leave a space between the previous value and the timeline url if the value is not empty.
if (!isEmpty(value)) {
newValue = `${value} ${newValue}`;
}
onChange(newValue);
},
[value, onChange, formatUrl]
);
useEffect(() => {
if (insertTimeline != null && value != null) {
dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false }));
handleOnTimelineChange(
insertTimeline.timelineTitle,
insertTimeline.timelineSavedObjectId,
insertTimeline.graphEventId
);
dispatch(setInsertTimeline(null));
}
}, [insertTimeline, dispatch, handleOnTimelineChange, value]);
return {
handleOnTimelineChange,
};
};

View file

@ -1,21 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const CASE_CONNECTOR_DESC = i18n.translate(
'xpack.securitySolution.case.components.case.selectMessageText',
{
defaultMessage: 'Create or update a case.',
}
);
export const CASE_CONNECTOR_TITLE = i18n.translate(
'xpack.securitySolution.case.components.case.actionTypeTitle',
{
defaultMessage: 'Cases',
}
);

View file

@ -59,7 +59,7 @@ import {
IndexFieldsStrategyResponse,
} from '../common/search_strategy/index_fields';
import { SecurityAppStore } from './common/store/store';
import { getCaseConnectorUI } from './common/lib/connectors';
import { getCaseConnectorUI } from './cases/components/connectors';
import { licenseService } from './common/hooks/use_license';
import { LazyEndpointPolicyEditExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_edit_extension';
import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension';

View file

@ -1,70 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../../../../common/hooks/use_selector';
import { SecurityPageName } from '../../../../../common/constants';
import { getTimelineUrl, useFormatUrl } from '../../../../common/components/link_to';
import { CursorPosition } from '../../../../common/components/markdown_editor';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
import { setInsertTimeline } from '../../../store/timeline/actions';
export const useInsertTimeline = (value: string, onChange: (newValue: string) => void) => {
const dispatch = useDispatch();
const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
const [cursorPosition, setCursorPosition] = useState<CursorPosition>({
start: 0,
end: 0,
});
const insertTimeline = useShallowEqualSelector(timelineSelectors.selectInsertTimeline);
const handleOnTimelineChange = useCallback(
(title: string, id: string | null, graphEventId?: string) => {
const url = formatUrl(getTimelineUrl(id ?? '', graphEventId), {
absolute: true,
skipSearch: true,
});
const newValue: string = [
value.slice(0, cursorPosition.start),
cursorPosition.start === cursorPosition.end
? `[${title}](${url})`
: `[${value.slice(cursorPosition.start, cursorPosition.end)}](${url})`,
value.slice(cursorPosition.end),
].join('');
onChange(newValue);
},
[value, onChange, cursorPosition, formatUrl]
);
const handleCursorChange = useCallback((cp: CursorPosition) => {
setCursorPosition(cp);
}, []);
// insertTimeline selector is defined to attached a timeline to a case outside of the case page.
// FYI, if you are in the case page we only use handleOnTimelineChange to attach a timeline to a case.
useEffect(() => {
if (insertTimeline != null && value != null) {
dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false }));
handleOnTimelineChange(
insertTimeline.timelineTitle,
insertTimeline.timelineSavedObjectId,
insertTimeline.graphEventId
);
dispatch(setInsertTimeline(null));
}
}, [insertTimeline, dispatch, handleOnTimelineChange, value]);
return {
cursorPosition,
handleCursorChange,
handleOnTimelineChange,
};
};