[SECURITY SOLUTIONS] Bug case connector (#93104)

* bring back case connector to design

* disable connector sir in collection

* missing to only create collection type

* fix fields connector when you need to hide service-now sir
This commit is contained in:
Xavier Mouligneau 2021-03-01 22:46:22 -05:00 committed by GitHub
parent 90976ee119
commit 2903844dd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 568 additions and 219 deletions

View file

@ -447,6 +447,9 @@ export const CaseComponent = React.memo<CaseProps>(
caseFields={caseData.connector.fields}
connectors={connectors}
disabled={!userCanCrud}
hideConnectorServiceNowSir={
subCaseId != null || caseData.type === CaseType.collection
}
isLoading={isLoadingConnectors || (isLoading && updateKey === 'connector')}
onSubmit={onSubmitConnector}
selectedConnector={caseData.connector.id}

View file

@ -34,22 +34,122 @@ describe('ConnectorsDropdown', () => {
test('it formats the connectors correctly', () => {
const selectProps = wrapper.find(EuiSuperSelect).props();
expect(selectProps.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: 'none',
'data-test-subj': 'dropdown-connector-no-connector',
}),
expect.objectContaining({
value: 'servicenow-1',
'data-test-subj': 'dropdown-connector-servicenow-1',
}),
expect.objectContaining({
value: 'resilient-2',
'data-test-subj': 'dropdown-connector-resilient-2',
}),
])
);
expect(selectProps.options).toMatchInlineSnapshot(`
Array [
Object {
"data-test-subj": "dropdown-connector-no-connector",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="minusInCircle"
/>
</EuiFlexItem>
<EuiFlexItem>
<span
data-test-subj="dropdown-connector-no-connector"
>
No connector selected
</span>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "none",
},
Object {
"data-test-subj": "dropdown-connector-servicenow-1",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="test-file-stub"
/>
</EuiFlexItem>
<EuiFlexItem>
<span>
My Connector
</span>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "servicenow-1",
},
Object {
"data-test-subj": "dropdown-connector-resilient-2",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="test-file-stub"
/>
</EuiFlexItem>
<EuiFlexItem>
<span>
My Connector 2
</span>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "resilient-2",
},
Object {
"data-test-subj": "dropdown-connector-jira-1",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="test-file-stub"
/>
</EuiFlexItem>
<EuiFlexItem>
<span>
Jira
</span>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "jira-1",
},
Object {
"data-test-subj": "dropdown-connector-servicenow-sir",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="test-file-stub"
/>
</EuiFlexItem>
<EuiFlexItem>
<span>
My Connector SIR
</span>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "servicenow-sir",
},
]
`);
});
test('it disables the dropdown', () => {
@ -79,4 +179,25 @@ describe('ConnectorsDropdown', () => {
expect(newWrapper.find('button span:not([data-euiicon-type])').text()).toEqual('My Connector');
});
test('if the props hideConnectorServiceNowSir is true, the connector should not be part of the list of options ', () => {
const newWrapper = mount(
<ConnectorsDropdown
{...props}
selectedConnector={'servicenow-1'}
hideConnectorServiceNowSir={true}
/>,
{
wrappingComponent: TestProviders,
}
);
const selectProps = newWrapper.find(EuiSuperSelect).props();
const options = selectProps.options as Array<{ 'data-test-subj': string }>;
expect(
options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-1')
).toBeTruthy();
expect(
options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-sir')
).toBeFalsy();
});
});

View file

@ -9,6 +9,7 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import styled from 'styled-components';
import { ConnectorTypes } from '../../../../../case/common/api';
import { ActionConnector } from '../../containers/configure/types';
import { connectorsConfiguration } from '../connectors';
import * as i18n from './translations';
@ -20,6 +21,7 @@ export interface Props {
onChange: (id: string) => void;
selectedConnector: string;
appendAddConnectorButton?: boolean;
hideConnectorServiceNowSir?: boolean;
}
const ICON_SIZE = 'm';
@ -61,29 +63,36 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({
onChange,
selectedConnector,
appendAddConnectorButton = false,
hideConnectorServiceNowSir = false,
}) => {
const connectorsAsOptions = useMemo(() => {
const connectorsFormatted = connectors.reduce(
(acc, connector) => [
...acc,
{
value: connector.id,
inputDisplay: (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIconExtended
type={connectorsConfiguration[connector.actionTypeId]?.logo ?? ''}
size={ICON_SIZE}
/>
</EuiFlexItem>
<EuiFlexItem>
<span>{connector.name}</span>
</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': `dropdown-connector-${connector.id}`,
},
],
(acc, connector) => {
if (hideConnectorServiceNowSir && connector.actionTypeId === ConnectorTypes.serviceNowSIR) {
return acc;
}
return [
...acc,
{
value: connector.id,
inputDisplay: (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIconExtended
type={connectorsConfiguration[connector.actionTypeId]?.logo ?? ''}
size={ICON_SIZE}
/>
</EuiFlexItem>
<EuiFlexItem>
<span>{connector.name}</span>
</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': `dropdown-connector-${connector.id}`,
},
];
},
[noConnectorOption]
);

View file

@ -22,6 +22,7 @@ interface ConnectorSelectorProps {
isEdit: boolean;
isLoading: boolean;
handleChange?: (newValue: string) => void;
hideConnectorServiceNowSir?: boolean;
}
export const ConnectorSelector = ({
connectors,
@ -32,6 +33,7 @@ export const ConnectorSelector = ({
isEdit = true,
isLoading = false,
handleChange,
hideConnectorServiceNowSir = false,
}: ConnectorSelectorProps) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const onChange = useCallback(
@ -58,6 +60,7 @@ export const ConnectorSelector = ({
<ConnectorsDropdown
connectors={connectors}
disabled={disabled}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
isLoading={isLoading}
onChange={onChange}
selectedConnector={isEmpty(field.value) ? 'none' : field.value}

View file

@ -9,7 +9,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { EuiCallOut } from '@elastic/eui';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { ActionParamsProps } from '../../../../../../triggers_actions_ui/public/types';
import { CommentType } from '../../../../../../case/common/api';
@ -21,13 +21,15 @@ import * as i18n from './translations';
const Container = styled.div`
${({ theme }) => `
margin-top: ${theme.eui?.euiSize ?? '16px'};
padding: ${theme.eui?.euiSizeS ?? '8px'} ${theme.eui?.euiSizeL ?? '24px'} ${
theme.eui?.euiSizeL ?? '24px'
} ${theme.eui?.euiSizeL ?? '24px'};
`}
`;
const defaultAlertComment = {
type: CommentType.generatedAlert,
alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{rule.id}}", "ruleName": "{{rule.name}}"}__SEPARATOR__{{/context.alerts}}]`,
alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{signal.rule.id}}", "ruleName": "{{signal.rule.name}}"}__SEPARATOR__{{/context.alerts}}]`,
};
const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionParams>> = ({
@ -90,12 +92,13 @@ const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionPara
]);
return (
<>
<EuiCallOut size="s" title={i18n.CASE_CONNECTOR_CALL_OUT_INFO} iconType="iInCircle" />
<Container>
<ExistingCase onCaseChanged={onCaseChanged} selectedCase={selectedCase} />
</Container>
</>
<Container>
<ExistingCase onCaseChanged={onCaseChanged} selectedCase={selectedCase} />
<EuiSpacer size="m" />
<EuiCallOut size="s" title={i18n.CASE_CONNECTOR_CALL_OUT_TITLE} iconType="iInCircle">
<p>{i18n.CASE_CONNECTOR_CALL_OUT_MSG}</p>
</EuiCallOut>
</Container>
);
};

View file

@ -5,22 +5,15 @@
* 2.0.
*/
import {
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiTextColor,
EuiLoadingSpinner,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import React, { memo, useEffect, useCallback, useState } from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { CaseType } from '../../../../../../case/common/api';
import { Case } from '../../../containers/types';
import { useDeleteCases } from '../../../containers/use_delete_cases';
import { useGetCase } from '../../../containers/use_get_case';
import { ConfirmDeleteCaseModal } from '../../confirm_delete_case';
import {
useGetCases,
DEFAULT_QUERY_PARAMS,
DEFAULT_FILTER_OPTIONS,
} from '../../../containers/use_get_cases';
import { useCreateCaseModal } from '../../use_create_case_modal';
import * as i18n from './translations';
import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown';
interface ExistingCaseProps {
selectedCase: string | null;
@ -28,76 +21,53 @@ interface ExistingCaseProps {
}
const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, selectedCase }) => {
const { data, isLoading, isError } = useGetCase(selectedCase ?? '');
const [createdCase, setCreatedCase] = useState<Case | null>(null);
const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases(DEFAULT_QUERY_PARAMS, {
...DEFAULT_FILTER_OPTIONS,
onlyCollectionType: true,
});
const onCaseCreated = useCallback(
(newCase: Case) => {
(newCase) => {
refetchCases();
onCaseChanged(newCase.id);
setCreatedCase(newCase);
},
[onCaseChanged]
[onCaseChanged, refetchCases]
);
const { modal, openModal } = useCreateCaseModal({ caseType: CaseType.collection, onCaseCreated });
const { modal, openModal } = useCreateCaseModal({
onCaseCreated,
caseType: CaseType.collection,
// FUTURE DEVELOPER
// We are making the assumption that this component is only used in rules creation
// that's why we want to hide ServiceNow SIR
hideConnectorServiceNowSir: true,
});
// Delete case
const {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isLoading: isDeleting,
isDeleted,
isDisplayConfirmDeleteModal,
} = useDeleteCases();
const onChange = useCallback(
(id: string) => {
if (id === ADD_CASE_BUTTON_ID) {
openModal();
return;
}
useEffect(() => {
if (isDeleted) {
setCreatedCase(null);
onCaseChanged('');
dispatchResetIsDeleted();
}
// onCaseChanged and/or dispatchResetIsDeleted causes re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDeleted]);
onCaseChanged(id);
},
[onCaseChanged, openModal]
);
useEffect(() => {
if (!isLoading && !isError && data != null) {
setCreatedCase(data);
onCaseChanged(data.id);
}
// onCaseChanged causes re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, isLoading, isError]);
const isCasesLoading = useMemo(
() => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'),
[isLoadingCases]
);
return (
<>
{createdCase == null && isEmpty(selectedCase) && (
<EuiButton fill fullWidth onClick={openModal}>
{i18n.CREATE_CASE}
</EuiButton>
)}
{createdCase == null && isLoading && <EuiLoadingSpinner size="m" />}
{createdCase != null && !isLoading && (
<>
<EuiCallOut title={i18n.CONNECTED_CASE} color="success">
<EuiTextColor color="default">
{createdCase.title}{' '}
{!isDeleting && (
<EuiButtonIcon color="danger" onClick={handleToggleModal} iconType="trash" />
)}
{isDeleting && <EuiLoadingSpinner size="m" />}
</EuiTextColor>
</EuiCallOut>
<ConfirmDeleteCaseModal
caseTitle={createdCase.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [createdCase])}
/>
</>
)}
<CasesDropdown
isLoading={isCasesLoading}
cases={cases.cases}
selectedCase={selectedCase ?? undefined}
onCaseChanged={onChange}
/>
{modal}
</>
);

View file

@ -40,7 +40,7 @@ export const CASE_CONNECTOR_COMMENT_REQUIRED = i18n.translate(
export const CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel',
{
defaultMessage: 'Case',
defaultMessage: 'Case allowing sub-cases',
}
);
@ -72,10 +72,18 @@ export const CASE_CONNECTOR_CASE_REQUIRED = i18n.translate(
}
);
export const CASE_CONNECTOR_CALL_OUT_INFO = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.callOutInfo',
export const CASE_CONNECTOR_CALL_OUT_TITLE = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.callOutTitle',
{
defaultMessage: 'All alerts after rule creation will be appended to the selected case.',
defaultMessage: 'Generated alerts will be attached to sub-cases',
}
);
export const CASE_CONNECTOR_CALL_OUT_MSG = i18n.translate(
'xpack.securitySolution.case.components.connectors.case.callOutMsg',
{
defaultMessage:
'A case can contain multiple sub-cases to allow grouping of generated alerts. Sub-cases will give more granular control over the status of these generated alerts and prevents having too many alerts attached to one case.',
}
);

View file

@ -8,6 +8,7 @@
import React, { memo, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ConnectorTypes } from '../../../../../case/common/api';
import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports';
import { useConnectors } from '../../containers/configure/use_connectors';
import { ConnectorSelector } from '../connector_selector/form';
@ -18,19 +19,32 @@ import { FormProps } from './schema';
interface Props {
isLoading: boolean;
hideConnectorServiceNowSir?: boolean;
}
interface ConnectorsFieldProps {
connectors: ActionConnector[];
field: FieldHook<FormProps['fields']>;
isEdit: boolean;
hideConnectorServiceNowSir?: boolean;
}
const ConnectorFields = ({ connectors, isEdit, field }: ConnectorsFieldProps) => {
const ConnectorFields = ({
connectors,
isEdit,
field,
hideConnectorServiceNowSir = false,
}: ConnectorsFieldProps) => {
const [{ connectorId }] = useFormData({ watch: ['connectorId'] });
const { setValue } = field;
const connector = getConnectorById(connectorId, connectors) ?? null;
let connector = getConnectorById(connectorId, connectors) ?? null;
if (
connector &&
hideConnectorServiceNowSir &&
connector.actionTypeId === ConnectorTypes.serviceNowSIR
) {
connector = null;
}
return (
<ConnectorFieldsForm
connector={connector}
@ -41,7 +55,7 @@ const ConnectorFields = ({ connectors, isEdit, field }: ConnectorsFieldProps) =>
);
};
const ConnectorComponent: React.FC<Props> = ({ isLoading }) => {
const ConnectorComponent: React.FC<Props> = ({ hideConnectorServiceNowSir = false, isLoading }) => {
const { getFields } = useFormContext();
const { loading: isLoadingConnectors, connectors } = useConnectors();
const handleConnectorChange = useCallback(
@ -61,6 +75,7 @@ const ConnectorComponent: React.FC<Props> = ({ isLoading }) => {
componentProps={{
connectors,
handleChange: handleConnectorChange,
hideConnectorServiceNowSir,
dataTestSubj: 'caseConnectors',
disabled: isLoading || isLoadingConnectors,
idAria: 'caseConnectors',
@ -74,6 +89,7 @@ const ConnectorComponent: React.FC<Props> = ({ isLoading }) => {
component={ConnectorFields}
componentProps={{
connectors,
hideConnectorServiceNowSir,
isEdit: true,
}}
/>

View file

@ -36,78 +36,84 @@ const MySpinner = styled(EuiLoadingSpinner)`
`;
interface Props {
hideConnectorServiceNowSir?: boolean;
withSteps?: boolean;
}
export const CreateCaseForm: React.FC<Props> = React.memo(({ withSteps = true }) => {
const { isSubmitting } = useFormContext();
export const CreateCaseForm: React.FC<Props> = React.memo(
({ hideConnectorServiceNowSir = false, withSteps = true }) => {
const { isSubmitting } = useFormContext();
const firstStep = useMemo(
() => ({
title: i18n.STEP_ONE_TITLE,
children: (
<>
<Title isLoading={isSubmitting} />
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>
<Tags isLoading={isSubmitting} />
<SyncAlertsToggle isLoading={isSubmitting} />
</Container>
<Container big>
<Description isLoading={isSubmitting} />
),
}),
[isSubmitting]
);
const thirdStep = useMemo(
() => ({
title: i18n.STEP_THREE_TITLE,
children: (
<Container>
<Connector
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
isLoading={isSubmitting}
/>
</Container>
</>
),
}),
[isSubmitting]
);
),
}),
[hideConnectorServiceNowSir, isSubmitting]
);
const secondStep = useMemo(
() => ({
title: i18n.STEP_TWO_TITLE,
children: (
<Container>
<SyncAlertsToggle isLoading={isSubmitting} />
</Container>
),
}),
[isSubmitting]
);
const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [
firstStep,
secondStep,
thirdStep,
]);
const thirdStep = useMemo(
() => ({
title: i18n.STEP_THREE_TITLE,
children: (
<Container>
<Connector isLoading={isSubmitting} />
</Container>
),
}),
[isSubmitting]
);
const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [
firstStep,
secondStep,
thirdStep,
]);
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}
{thirdStep.children}
</>
)}
</>
);
});
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}
{thirdStep.children}
</>
)}
</>
);
}
);
CreateCaseForm.displayName = 'CreateCaseForm';

View file

@ -19,7 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service'
import { useConnectors } from '../../containers/configure/use_connectors';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { Case } from '../../containers/types';
import { CaseType } from '../../../../../case/common/api';
import { CaseType, ConnectorTypes } from '../../../../../case/common/api';
const initialCaseValue: FormProps = {
description: '',
@ -31,29 +31,40 @@ const initialCaseValue: FormProps = {
};
interface Props {
caseType?: CaseType;
onSuccess?: (theCase: Case) => Promise<void>;
afterCaseCreated?: (theCase: Case) => Promise<void>;
caseType?: CaseType;
hideConnectorServiceNowSir?: boolean;
onSuccess?: (theCase: Case) => Promise<void>;
}
export const FormContext: React.FC<Props> = ({
afterCaseCreated,
caseType = CaseType.individual,
children,
hideConnectorServiceNowSir,
onSuccess,
afterCaseCreated,
}) => {
const { connectors } = useConnectors();
const { connector: configurationConnector } = useCaseConfigure();
const { postCase } = usePostCase();
const { pushCaseToExternalService } = usePostPushToService();
const connectorId = useMemo(
() =>
connectors.some((connector) => connector.id === configurationConnector.id)
? configurationConnector.id
: 'none',
[configurationConnector.id, connectors]
);
const connectorId = useMemo(() => {
if (
hideConnectorServiceNowSir &&
configurationConnector.type === ConnectorTypes.serviceNowSIR
) {
return 'none';
}
return connectors.some((connector) => connector.id === configurationConnector.id)
? configurationConnector.id
: 'none';
}, [
configurationConnector.id,
configurationConnector.type,
connectors,
hideConnectorServiceNowSir,
]);
const submitCase = useCallback(
async (

View file

@ -34,7 +34,6 @@ import * as i18n from './translations';
interface EditConnectorProps {
caseFields: ConnectorTypeFields['fields'];
connectors: ActionConnector[];
disabled?: boolean;
isLoading: boolean;
onSubmit: (
connectorId: string,
@ -44,6 +43,8 @@ interface EditConnectorProps {
) => void;
selectedConnector: string;
userActions: CaseUserActions[];
disabled?: boolean;
hideConnectorServiceNowSir?: boolean;
}
const MyFlexGroup = styled(EuiFlexGroup)`
@ -105,6 +106,7 @@ export const EditConnector = React.memo(
caseFields,
connectors,
disabled = false,
hideConnectorServiceNowSir = false,
isLoading,
onSubmit,
selectedConnector,
@ -234,6 +236,7 @@ export const EditConnector = React.memo(
dataTestSubj: 'caseConnectors',
defaultValue: selectedConnector,
disabled,
hideConnectorServiceNowSir,
idAria: 'caseConnectors',
isEdit: editConnector,
isLoading,

View file

@ -21,6 +21,7 @@ export interface CreateCaseModalProps {
onCloseCaseModal: () => void;
onSuccess: (theCase: Case) => Promise<void>;
caseType?: CaseType;
hideConnectorServiceNowSir?: boolean;
}
const Container = styled.div`
@ -35,6 +36,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
onCloseCaseModal,
onSuccess,
caseType = CaseType.individual,
hideConnectorServiceNowSir = false,
}) => {
return isModalOpen ? (
<EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal">
@ -42,8 +44,15 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
<EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormContext caseType={caseType} onSuccess={onSuccess}>
<CreateCaseForm withSteps={false} />
<FormContext
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
caseType={caseType}
onSuccess={onSuccess}
>
<CreateCaseForm
withSteps={false}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
/>
<Container>
<SubmitCaseButton />
</Container>

View file

@ -13,6 +13,7 @@ import { CreateCaseModal } from './create_case_modal';
export interface UseCreateCaseModalProps {
onCaseCreated: (theCase: Case) => void;
caseType?: CaseType;
hideConnectorServiceNowSir?: boolean;
}
export interface UseCreateCaseModalReturnedValues {
modal: JSX.Element;
@ -24,6 +25,7 @@ export interface UseCreateCaseModalReturnedValues {
export const useCreateCaseModal = ({
caseType = CaseType.individual,
onCaseCreated,
hideConnectorServiceNowSir = false,
}: UseCreateCaseModalProps) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
@ -41,6 +43,7 @@ export const useCreateCaseModal = ({
modal: (
<CreateCaseModal
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onSuccess={onSuccess}
@ -50,7 +53,7 @@ export const useCreateCaseModal = ({
closeModal,
openModal,
}),
[caseType, closeModal, isModalOpen, onSuccess, openModal]
[caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal]
);
return state;

View file

@ -15,6 +15,7 @@ import {
CasesResponse,
CasesStatusResponse,
CaseStatuses,
CaseType,
CaseUserActionsResponse,
CommentRequest,
CommentType,
@ -165,6 +166,7 @@ export const getSubCaseUserActions = async (
export const getCases = async ({
filterOptions = {
onlyCollectionType: false,
search: '',
reporters: [],
status: CaseStatuses.open,
@ -183,6 +185,7 @@ export const getCases = async ({
tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`),
status: filterOptions.status,
...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}),
...(filterOptions.onlyCollectionType ? { type: CaseType.collection } : {}),
...queryParams,
};
const response = await KibanaServices.get().http.fetch<CasesFindResponse>(`${CASES_URL}/_find`, {

View file

@ -99,6 +99,7 @@ export interface FilterOptions {
status: CaseStatuses;
tags: string[];
reporters: User[];
onlyCollectionType?: boolean;
}
export interface CasesStatus {

View file

@ -97,6 +97,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
reporters: [],
status: CaseStatuses.open,
tags: [],
onlyCollectionType: false,
};
export const DEFAULT_QUERY_PARAMS: QueryParams = {
@ -129,10 +130,13 @@ export interface UseGetCases extends UseGetCasesState {
setSelectedCases: (mySelectedCases: Case[]) => void;
}
export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => {
export const useGetCases = (
initialQueryParams?: QueryParams,
initialFilterOptions?: FilterOptions
): UseGetCases => {
const [state, dispatch] = useReducer(dataFetchReducer, {
data: initialData,
filterOptions: DEFAULT_FILTER_OPTIONS,
filterOptions: initialFilterOptions ?? DEFAULT_FILTER_OPTIONS,
isError: false,
loading: [],
queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS,

View file

@ -8,10 +8,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import { RuleActionsField } from './index';
import { getSupportedActions, RuleActionsField } from './index';
import { useForm, Form } from '../../../../shared_imports';
import { useKibana } from '../../../../common/lib/kibana';
import { useFormFieldMock } from '../../../../common/mock';
import { ActionType } from '../../../../../../actions/common';
jest.mock('../../../../common/lib/kibana');
describe('RuleActionsField', () => {
@ -45,7 +46,11 @@ describe('RuleActionsField', () => {
return (
<Form form={form}>
<RuleActionsField field={field} messageVariables={messageVariables} />
<RuleActionsField
field={field}
messageVariables={messageVariables}
hasErrorOnCreationCaseAction={false}
/>
</Form>
);
};
@ -53,4 +58,63 @@ describe('RuleActionsField', () => {
expect(wrapper.dive().find('ActionForm')).toHaveLength(0);
});
describe('#getSupportedActions', () => {
const actions: ActionType[] = [
{
id: '.jira',
name: 'My Jira',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
minimumLicenseRequired: 'gold',
},
{
id: '.case',
name: 'Cases',
enabled: true,
enabledInConfig: false,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
];
it('if we have an error on case action creation, we do not support case connector', () => {
expect(getSupportedActions(actions, true)).toMatchInlineSnapshot(`
Array [
Object {
"enabled": true,
"enabledInConfig": false,
"enabledInLicense": true,
"id": ".jira",
"minimumLicenseRequired": "gold",
"name": "My Jira",
},
]
`);
});
it('if we do NOT have an error on case action creation, we are supporting case connector', () => {
expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(`
Array [
Object {
"enabled": true,
"enabledInConfig": false,
"enabledInLicense": true,
"id": ".jira",
"minimumLicenseRequired": "gold",
"name": "My Jira",
},
Object {
"enabled": true,
"enabledInConfig": false,
"enabledInLicense": true,
"id": ".case",
"minimumLicenseRequired": "basic",
"name": "Cases",
},
]
`);
});
});
});

View file

@ -26,6 +26,7 @@ import { FORM_ERRORS_TITLE } from './translations';
interface Props {
field: FieldHook;
hasErrorOnCreationCaseAction: boolean;
messageVariables: ActionVariables;
}
@ -39,7 +40,44 @@ const FieldErrorsContainer = styled.div`
}
`;
export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) => {
const ContainerActions = styled.div.attrs(
({ className = '', $caseIndexes = [] }: { className?: string; $caseIndexes: string[] }) => ({
className,
})
)<{ $caseIndexes: string[] }>`
${({ $caseIndexes }) =>
$caseIndexes.map(
(index) => `
div[id="${index}"].euiAccordion__childWrapper .euiAccordion__padding--l {
padding: 0px;
.euiFlexGroup {
display: none;
}
.euiSpacer.euiSpacer--xl {
height: 0px;
}
}
`
)}
`;
export const getSupportedActions = (
actionTypes: ActionType[],
hasErrorOnCreationCaseAction: boolean
): ActionType[] => {
return actionTypes.filter((actionType) => {
if (actionType.id === '.case' && hasErrorOnCreationCaseAction) {
return false;
}
return NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id);
});
};
export const RuleActionsField: React.FC<Props> = ({
field,
hasErrorOnCreationCaseAction,
messageVariables,
}) => {
const [fieldErrors, setFieldErrors] = useState<string | null>(null);
const [supportedActionTypes, setSupportedActionTypes] = useState<ActionType[] | undefined>();
const form = useFormContext();
@ -54,6 +92,17 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
[field.value]
);
const caseActionIndexes = useMemo(
() =>
actions.reduce<string[]>((acc, action, actionIndex) => {
if (action.actionTypeId === '.case') {
return [...acc, `${actionIndex}`];
}
return acc;
}, []),
[actions]
);
const setActionIdByIndex = useCallback(
(id: string, index: number) => {
const updatedActions = [...(actions as Array<Partial<AlertAction>>)];
@ -83,13 +132,11 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
useEffect(() => {
(async function () {
const actionTypes = await loadActionTypes({ http });
const supportedTypes = actionTypes.filter((actionType) =>
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id)
);
const supportedTypes = getSupportedActions(actionTypes, hasErrorOnCreationCaseAction);
setSupportedActionTypes(supportedTypes);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [hasErrorOnCreationCaseAction]);
useEffect(() => {
if (isSubmitting || !field.errors.length) {
@ -104,7 +151,7 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
if (!supportedActionTypes) return <></>;
return (
<>
<ContainerActions $caseIndexes={caseActionIndexes}>
{fieldErrors ? (
<>
<FieldErrorsContainer>
@ -126,6 +173,6 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
actionTypes={supportedActionTypes}
defaultActionMessage={DEFAULT_ACTION_MESSAGE}
/>
</>
</ContainerActions>
);
};

View file

@ -36,6 +36,7 @@ import { useKibana } from '../../../../common/lib/kibana';
import { getSchema } from './schema';
import * as I18n from './translations';
import { APP_ID } from '../../../../../common/constants';
import { useManageCaseAction } from './use_manage_case_action';
interface StepRuleActionsProps extends RuleStepProps {
defaultValues?: ActionsStepRule | null;
@ -70,6 +71,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
setForm,
actionMessageParams,
}) => {
const [isLoadingCaseAction, hasErrorOnCreationCaseAction] = useManageCaseAction();
const {
services: {
application,
@ -138,13 +140,14 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
() => ({
idAria: 'detectionEngineStepRuleActionsThrottle',
isDisabled: isLoading,
isLoading: isLoadingCaseAction,
dataTestSubj: 'detectionEngineStepRuleActionsThrottle',
hasNoInitialSelection: false,
euiFieldProps: {
options: throttleOptions,
},
}),
[isLoading, throttleOptions]
[isLoading, isLoadingCaseAction, throttleOptions]
);
const displayActionsOptions = useMemo(
@ -157,13 +160,14 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
component={RuleActionsField}
componentProps={{
messageVariables: actionMessageParams,
hasErrorOnCreationCaseAction,
}}
/>
</>
) : (
<UseField path="actions" component={GhostFormField} />
),
[throttle, actionMessageParams]
[throttle, actionMessageParams, hasErrorOnCreationCaseAction]
);
// only display the actions dropdown if the user has "read" privileges for actions
const displayActionsDropDown = useMemo(() => {

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useRef, useState } from 'react';
import { ACTION_URL } from '../../../../../../case/common/constants';
import { KibanaServices } from '../../../../common/lib/kibana';
interface CaseAction {
actionTypeId: string;
id: string;
isPreconfigured: boolean;
name: string;
referencedByCount: number;
}
const CASE_ACTION_NAME = 'Cases';
export const useManageCaseAction = () => {
const hasInit = useRef(true);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
const abortCtrl = new AbortController();
const fetchActions = async () => {
try {
const actions = await KibanaServices.get().http.fetch<CaseAction[]>(ACTION_URL, {
method: 'GET',
signal: abortCtrl.signal,
});
if (!actions.some((a) => a.actionTypeId === '.case' && a.name === CASE_ACTION_NAME)) {
await KibanaServices.get().http.post<CaseAction[]>(`${ACTION_URL}/action`, {
method: 'POST',
body: JSON.stringify({
actionTypeId: '.case',
config: {},
name: CASE_ACTION_NAME,
secrets: {},
}),
signal: abortCtrl.signal,
});
}
setLoading(false);
} catch {
setLoading(false);
setHasError(true);
}
};
if (hasInit.current) {
hasInit.current = false;
fetchActions();
}
return () => {
abortCtrl.abort();
};
}, []);
return [loading, hasError];
};

View file

@ -17199,7 +17199,6 @@
"xpack.securitySolution.case.common.noConnector": "コネクターを選択していません",
"xpack.securitySolution.case.components.connectors.case.actionTypeTitle": "ケース",
"xpack.securitySolution.case.components.connectors.case.addNewCaseOption": "新規ケースの追加",
"xpack.securitySolution.case.components.connectors.case.callOutInfo": "ルールを作成した後のすべてのアラートは、選択したケースの最後に追加されます。",
"xpack.securitySolution.case.components.connectors.case.caseRequired": "ケースの選択が必要です。",
"xpack.securitySolution.case.components.connectors.case.casesDropdownPlaceholder": "ケースを選択",
"xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel": "ケース",

View file

@ -17242,7 +17242,6 @@
"xpack.securitySolution.case.common.noConnector": "未选择任何连接器",
"xpack.securitySolution.case.components.connectors.case.actionTypeTitle": "案例",
"xpack.securitySolution.case.components.connectors.case.addNewCaseOption": "添加新案例",
"xpack.securitySolution.case.components.connectors.case.callOutInfo": "规则创建后的所有告警将追加到选定案例。",
"xpack.securitySolution.case.components.connectors.case.caseRequired": "必须选择策略。",
"xpack.securitySolution.case.components.connectors.case.casesDropdownPlaceholder": "选择案例",
"xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel": "案例",