[Cases] Swimlane: Fix bug when creating the connector with empty mapping. (#103446)

This commit is contained in:
Christos Nasikas 2021-06-30 18:08:04 +03:00 committed by GitHub
parent affe82b73c
commit 3ac067fc91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 167 additions and 155 deletions

View file

@ -4,21 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiCallOut,
EuiFieldText,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { EuiCallOut, EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import * as i18n from '../translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { useGetApplication } from '../use_get_application';
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from '../types';
import { SwimlaneActionConnector } from '../types';
import { IErrorObject } from '../../../../../types';
interface Props {
@ -27,8 +18,6 @@ interface Props {
editActionSecrets: (property: string, value: any) => void;
errors: IErrorObject;
readOnly: boolean;
updateCurrentStep: (step: number) => void;
updateFields: (items: SwimlaneFieldMappingConfig[]) => void;
}
const SwimlaneConnectionComponent: React.FunctionComponent<Props> = ({
@ -37,33 +26,10 @@ const SwimlaneConnectionComponent: React.FunctionComponent<Props> = ({
editActionSecrets,
errors,
readOnly,
updateCurrentStep,
updateFields,
}) => {
const {
notifications: { toasts },
} = useKibana().services;
const { apiUrl, appId } = action.config;
const { apiToken } = action.secrets;
const { docLinks } = useKibana().services;
const { getApplication } = useGetApplication({
toastNotifications: toasts,
apiToken,
appId,
apiUrl,
});
const isValid = apiUrl && apiToken && appId;
const connectSwimlane = useCallback(async () => {
// fetch swimlane application configuration
const application = await getApplication();
if (application?.fields) {
const allFields = application.fields;
updateFields(allFields);
updateCurrentStep(2);
}
}, [getApplication, updateCurrentStep, updateFields]);
const onChangeConfig = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, key: 'apiUrl' | 'appId') => {
@ -186,14 +152,6 @@ const SwimlaneConnectionComponent: React.FunctionComponent<Props> = ({
/>
</>
</EuiFormRow>
<EuiSpacer />
<EuiButton
disabled={!isValid}
onClick={connectSwimlane}
data-test-subj="swimlaneConfigureMapping"
>
{i18n.SW_RETRIEVE_CONFIGURATION_LABEL}
</EuiButton>
</>
);
};

View file

@ -6,13 +6,7 @@
*/
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
import {
EuiButton,
EuiFormRow,
EuiComboBox,
EuiComboBoxOptionOption,
EuiButtonGroup,
} from '@elastic/eui';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption, EuiButtonGroup } from '@elastic/eui';
import * as i18n from '../translations';
import {
SwimlaneActionConnector,
@ -102,10 +96,6 @@ const SwimlaneFieldsComponent: React.FC<Props> = ({
[errors]
);
const resetConnection = useCallback(() => {
updateCurrentStep(1);
}, [updateCurrentStep]);
const editMappings = useCallback(
(key: keyof SwimlaneMappingConfig, e: Array<EuiComboBoxOptionOption<string>>) => {
if (e.length === 0) {
@ -132,14 +122,6 @@ const SwimlaneFieldsComponent: React.FC<Props> = ({
[editActionConfig, fieldIdMap, mappings]
);
/**
* Connector type needs to be updated on mount to All.
* Otherwise it is undefined and this will cause an error
* if the user saves the connector without any mapping
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => editActionConfig('connectorType', connectorType), []);
useEffect(() => {
if (connectorType !== prevConnectorType.current) {
prevConnectorType.current = connectorType;
@ -305,7 +287,6 @@ const SwimlaneFieldsComponent: React.FC<Props> = ({
</EuiFormRow>
</>
)}
<EuiButton onClick={resetConnection}>{i18n.SW_CONFIGURE_API_LABEL}</EuiButton>
</>
);
};

View file

@ -7,6 +7,7 @@
import { isEmpty } from 'lodash';
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import {
ActionTypeModel,
ConnectorValidationResult,
@ -18,10 +19,23 @@ import {
SwimlaneSecrets,
SwimlaneActionParams,
} from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
import { validateMappingForConnector } from './helpers';
export const SW_SELECT_MESSAGE_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText',
{
defaultMessage: 'Create record in Swimlane',
}
);
export const SW_ACTION_TYPE_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle',
{
defaultMessage: 'Create Swimlane Record',
}
);
export function getActionType(): ActionTypeModel<
SwimlaneConfig,
SwimlaneSecrets,
@ -30,11 +44,12 @@ export function getActionType(): ActionTypeModel<
return {
id: '.swimlane',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.SW_SELECT_MESSAGE_TEXT,
actionTypeTitle: i18n.SW_ACTION_TYPE_TITLE,
selectMessage: SW_SELECT_MESSAGE_TEXT,
actionTypeTitle: SW_ACTION_TYPE_TITLE,
validateConnector: async (
action: SwimlaneActionConnector
): Promise<ConnectorValidationResult<SwimlaneConfig, SwimlaneSecrets>> => {
const translations = await import('./translations');
const configErrors = {
apiUrl: new Array<string>(),
appId: new Array<string>(),
@ -51,19 +66,22 @@ export function getActionType(): ActionTypeModel<
};
if (!action.config.apiUrl) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, translations.SW_API_URL_REQUIRED];
} else if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, translations.SW_API_URL_INVALID];
}
}
if (!action.secrets.apiToken) {
secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.SW_REQUIRED_API_TOKEN_TEXT];
secretsErrors.apiToken = [
...secretsErrors.apiToken,
translations.SW_REQUIRED_API_TOKEN_TEXT,
];
}
if (!action.config.appId) {
configErrors.appId = [...configErrors.appId, i18n.SW_REQUIRED_APP_ID_TEXT];
configErrors.appId = [...configErrors.appId, translations.SW_REQUIRED_APP_ID_TEXT];
}
const mappingErrors = validateMappingForConnector(
@ -80,6 +98,7 @@ export function getActionType(): ActionTypeModel<
validateParams: async (
actionParams: SwimlaneActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
'subActionParams.incident.ruleName': new Array<string>(),
'subActionParams.incident.alertId': new Array<string>(),
@ -91,11 +110,11 @@ export function getActionType(): ActionTypeModel<
const hasIncident = actionParams.subActionParams && actionParams.subActionParams.incident;
if (hasIncident && !actionParams.subActionParams.incident.ruleName?.length) {
errors['subActionParams.incident.ruleName'].push(i18n.SW_REQUIRED_RULE_NAME);
errors['subActionParams.incident.ruleName'].push(translations.SW_REQUIRED_RULE_NAME);
}
if (hasIncident && !actionParams.subActionParams.incident.alertId?.length) {
errors['subActionParams.incident.alertId'].push(i18n.SW_REQUIRED_ALERT_ID);
errors['subActionParams.incident.alertId'].push(translations.SW_REQUIRED_ALERT_ID);
}
return validationResult;

View file

@ -5,94 +5,153 @@
* 2.0.
*/
import React, { Fragment, useCallback, useMemo, useState } from 'react';
import { EuiForm, EuiSpacer, EuiStepsHorizontal, EuiStepStatus } from '@elastic/eui';
import * as i18n from './translations';
import React, { Fragment, useCallback, useMemo, useState, useEffect } from 'react';
import {
EuiForm,
EuiSpacer,
EuiStepsHorizontal,
EuiStepStatus,
EuiButton,
EuiFormRow,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionConnectorFieldsProps } from '../../../../types';
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from './types';
import {
SwimlaneActionConnector,
SwimlaneConnectorType,
SwimlaneFieldMappingConfig,
} from './types';
import { SwimlaneConnection, SwimlaneFields } from './steps';
import { useGetApplication } from './use_get_application';
import * as i18n from './translations';
const SwimlaneActionConnectorFields: React.FunctionComponent<
ActionConnectorFieldsProps<SwimlaneActionConnector>
> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => {
const {
notifications: { toasts },
} = useKibana().services;
const { apiUrl, appId, mappings, connectorType } = action.config;
const { apiToken } = action.secrets;
const { getApplication, isLoading: isLoadingApplication } = useGetApplication({
toastNotifications: toasts,
apiToken,
appId,
apiUrl,
});
const hasConfigurationErrors =
errors.apiUrl?.length > 0 || errors.appId?.length > 0 || errors.apiToken?.length > 0;
const [currentStep, setCurrentStep] = useState<number>(1);
const [stepsStatuses, setStepsStatuses] = useState<{
connection: EuiStepStatus;
fields: EuiStepStatus;
}>({ connection: 'incomplete', fields: 'incomplete' });
const [fields, setFields] = useState<SwimlaneFieldMappingConfig[]>([]);
const updateCurrentStep = useCallback(
(step: number) => {
setCurrentStep(step);
if (step === 2) {
setStepsStatuses((statuses) => ({ ...statuses, connection: 'complete' }));
} else if (step === 1) {
setStepsStatuses({
fields: 'incomplete',
connection: 'incomplete',
});
editActionConfig('mappings', action.config.mappings);
}
},
[action.config.mappings, editActionConfig]
const updateCurrentStep = useCallback((step: number) => {
setCurrentStep(step);
}, []);
const onNextStep = useCallback(async () => {
// fetch swimlane application configuration
const application = await getApplication();
if (application?.fields) {
const allFields = application.fields;
setFields(allFields);
setCurrentStep(2);
}
}, [getApplication]);
const resetConnection = useCallback(() => {
setCurrentStep(1);
}, []);
const hasMappingErrors = useMemo(
() => Object.values(errors?.mappings ?? {}).some((mappingError) => mappingError.length !== 0),
[errors?.mappings]
);
const setupSteps = useMemo(
const steps = useMemo(
() => [
{
title: i18n.SW_CONFIGURE_CONNECTION_LABEL,
status: stepsStatuses.connection,
isSelected: currentStep === 1,
isComplete: currentStep === 2,
onClick: () => updateCurrentStep(1),
},
{
title: i18n.SW_MAPPING_TITLE_TEXT_FIELD_LABEL,
disabled: stepsStatuses.connection !== 'complete',
status: stepsStatuses.fields,
onClick: () => updateCurrentStep(2),
disabled: hasConfigurationErrors || isLoadingApplication,
isSelected: currentStep === 2,
onClick: onNextStep,
status: hasMappingErrors ? ('danger' as EuiStepStatus) : undefined,
},
],
[stepsStatuses.connection, stepsStatuses.fields, updateCurrentStep]
[
currentStep,
hasConfigurationErrors,
hasMappingErrors,
isLoadingApplication,
onNextStep,
updateCurrentStep,
]
);
const editActionConfigCb = useCallback(
(k: string, v: string) => {
editActionConfig(k, v);
if (
Object.values(errors?.mappings ?? {}).every((mappingError) => mappingError.length === 0)
) {
setStepsStatuses((statuses) => ({ ...statuses, fields: 'complete' }));
} else {
setStepsStatuses((statuses) => ({ ...statuses, fields: 'incomplete' }));
}
},
[editActionConfig, errors?.mappings]
);
/**
* Connector type needs to be updated on mount to All.
* Otherwise it is undefined and this will cause an error
* if the user saves the connector without going to the
* second step. Same for mapping.
*/
useEffect(() => {
editActionConfig('connectorType', connectorType ?? SwimlaneConnectorType.All);
editActionConfig('mappings', mappings ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Fragment>
<EuiStepsHorizontal steps={setupSteps} />
<EuiStepsHorizontal steps={steps} />
<EuiSpacer size="l" />
<EuiForm>
{currentStep === 1 && (
<SwimlaneConnection
action={action}
editActionConfig={editActionConfigCb}
editActionSecrets={editActionSecrets}
readOnly={readOnly}
errors={errors}
updateCurrentStep={updateCurrentStep}
updateFields={setFields}
/>
<>
<SwimlaneConnection
action={action}
editActionConfig={editActionConfig}
editActionSecrets={editActionSecrets}
readOnly={readOnly}
errors={errors}
/>
<EuiSpacer />
<EuiFormRow fullWidth helpText={i18n.SW_FIELDS_BUTTON_HELP_TEXT}>
<EuiButton
disabled={hasConfigurationErrors || isLoadingApplication}
isLoading={isLoadingApplication}
onClick={onNextStep}
data-test-subj="swimlaneConfigureMapping"
iconType="arrowRight"
iconSide="right"
>
{i18n.SW_NEXT}
</EuiButton>
</EuiFormRow>
</>
)}
{currentStep === 2 && (
<SwimlaneFields
action={action}
editActionConfig={editActionConfigCb}
updateCurrentStep={updateCurrentStep}
fields={fields}
errors={errors}
/>
<>
<SwimlaneFields
action={action}
editActionConfig={editActionConfig}
updateCurrentStep={updateCurrentStep}
fields={fields}
errors={errors}
/>
<EuiButton onClick={resetConnection} iconType="arrowLeft">
{i18n.SW_BACK}
</EuiButton>
</>
)}
</EuiForm>
</Fragment>

View file

@ -7,20 +7,6 @@
import { i18n } from '@kbn/i18n';
export const SW_SELECT_MESSAGE_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText',
{
defaultMessage: 'Create record in Swimlane',
}
);
export const SW_ACTION_TYPE_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle',
{
defaultMessage: 'Create Swimlane Record',
}
);
export const SW_REQUIRED_RULE_NAME = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredRuleName',
{
@ -190,11 +176,6 @@ export const SW_RETRIEVE_CONFIGURATION_LABEL = i18n.translate(
{ defaultMessage: 'Configure Fields' }
);
export const SW_CONFIGURE_API_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureAPILabel',
{ defaultMessage: 'Configure API' }
);
export const SW_CONNECTOR_TYPE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.connectorType',
{
@ -220,7 +201,7 @@ export const EMPTY_MAPPING_WARNING_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningDesc',
{
defaultMessage:
'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.',
'This connector cannot be selected because it is missing the required alert field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.',
}
);
@ -273,10 +254,24 @@ export const SW_REQUIRED_ALERT_ID = i18n.translate(
}
);
export const SW_ALERT_SOURCE_TOOLTIP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceTooltip',
export const SW_BACK = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.prevStep',
{
defaultMessage: 'The index of the alert. Use {index} in Detections.',
values: { index: '{{context.rule.output_index}}' },
defaultMessage: 'Back',
}
);
export const SW_NEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.nextStep',
{
defaultMessage: 'Next',
}
);
export const SW_FIELDS_BUTTON_HELP_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.nextStepHelpText',
{
defaultMessage:
'If field mappings are not configured, Swimlane connector type will be set to all.',
}
);