[Actions] adds a Test Connector tab in the Connectors list (#77365)

Adds a tab in the _Edit Alert_ flyout which allows the user to _test_ their connector by executing it using an example action. The execution relies on the connector being updated, so is only enabled when there are no saved changes in the Connector form itself.
This commit is contained in:
Gidi Meir Morris 2020-09-22 11:18:33 +01:00 committed by GitHub
parent d3dd50520b
commit 41a7f1a1c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 932 additions and 202 deletions

View file

@ -24,3 +24,13 @@ export interface ActionResult {
config: Record<string, any>;
isPreconfigured: boolean;
}
// the result returned from an action type executor function
export interface ActionTypeExecutorResult<Data> {
actionId: string;
status: 'ok' | 'error';
message?: string;
serviceMessage?: string;
data?: Data;
retry?: null | boolean | Date;
}

View file

@ -284,4 +284,47 @@ describe('execute()', () => {
]
`);
});
test('resolves with an error when an error occurs in the indexing operation', async () => {
const secrets = {};
// minimal params
const config = { index: 'index-value', refresh: false, executionTimeField: null };
const params = {
documents: [{ '': 'bob' }],
};
const actionId = 'some-id';
services.callCluster.mockResolvedValue({
took: 0,
errors: true,
items: [
{
index: {
_index: 'indexme',
_id: '7buTjHQB0SuNSiS9Hayt',
status: 400,
error: {
type: 'mapper_parsing_exception',
reason: 'failed to parse',
caused_by: {
type: 'illegal_argument_exception',
reason: 'field name cannot be an empty string',
},
},
},
},
],
});
expect(await actionType.executor({ actionId, config, secrets, params, services }))
.toMatchInlineSnapshot(`
Object {
"actionId": "some-id",
"message": "error indexing documents",
"serviceMessage": "failed to parse (field name cannot be an empty string)",
"status": "error",
}
`);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { curry } from 'lodash';
import { curry, find } from 'lodash';
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
@ -85,21 +85,39 @@ async function executor(
refresh: config.refresh,
};
let result;
try {
result = await services.callCluster('bulk', bulkParams);
} catch (err) {
const message = i18n.translate('xpack.actions.builtin.esIndex.errorIndexingErrorMessage', {
defaultMessage: 'error indexing documents',
});
logger.error(`error indexing documents: ${err.message}`);
return {
status: 'error',
actionId,
message,
serviceMessage: err.message,
};
}
const result = await services.callCluster('bulk', bulkParams);
return { status: 'ok', data: result, actionId };
const err = find(result.items, 'index.error.reason');
if (err) {
return wrapErr(
`${err.index.error!.reason}${
err.index.error?.caused_by ? ` (${err.index.error?.caused_by?.reason})` : ''
}`,
actionId,
logger
);
}
return { status: 'ok', data: result, actionId };
} catch (err) {
return wrapErr(err.message, actionId, logger);
}
}
function wrapErr(
errMessage: string,
actionId: string,
logger: Logger
): ActionTypeExecutorResult<unknown> {
const message = i18n.translate('xpack.actions.builtin.esIndex.errorIndexingErrorMessage', {
defaultMessage: 'error indexing documents',
});
logger.error(`error indexing documents: ${errMessage}`);
return {
status: 'error',
actionId,
message,
serviceMessage: errMessage,
};
}

View file

@ -15,6 +15,8 @@ import {
SavedObjectsClientContract,
SavedObjectAttributes,
} from '../../../../src/core/server';
import { ActionTypeExecutorResult } from '../common';
export { ActionTypeExecutorResult } from '../common';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: KibanaRequest) => Services;
@ -80,16 +82,6 @@ export interface FindActionResult extends ActionResult {
referencedByCount: number;
}
// the result returned from an action type executor function
export interface ActionTypeExecutorResult<Data> {
actionId: string;
status: 'ok' | 'error';
message?: string;
serviceMessage?: string;
data?: Data;
retry?: null | boolean | Date;
}
// signature of the action type executor function
export type ExecutorType<Config, Secrets, Params, ResultData> = (
options: ActionTypeExecutorOptions<Config, Secrets, Params>

View file

@ -61,6 +61,7 @@ export const AddMessageVariables: React.FunctionComponent<Props> = ({
<EuiButtonIcon
id={`${paramsProperty}AddVariableButton`}
data-test-subj={`${paramsProperty}AddVariableButton`}
isDisabled={(messageVariables?.length ?? 0) === 0}
title={addVariableButtonTitle}
onClick={() => setIsVariablesPopoverOpen(true)}
iconType="indexOpen"

View file

@ -32,48 +32,47 @@ export const IndexParamsFields = ({
};
return (
<>
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'documents'}
inputTargetValue={
documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'documents'}
data-test-subj="documentToIndex"
inputTargetValue={
documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel',
{
defaultMessage: 'Document to index',
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel',
{
defaultMessage: 'Document to index',
}
)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel',
{
defaultMessage: 'Code editor',
}
)}
errors={errors.documents as string[]}
onDocumentsChange={onDocumentsChange}
helpText={
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/index-action-type.html#index-action-configuration`}
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.indexAction.indexDocumentHelpLabel"
defaultMessage="Index document example."
/>
</EuiLink>
)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel',
{
defaultMessage: 'Code editor',
}
onBlur={() => {
if (
!(documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined)
) {
// set document as empty to turn on the validation for non empty valid JSON object
onDocumentsChange('{}');
}
}}
/>
</>
)}
errors={errors.documents as string[]}
onDocumentsChange={onDocumentsChange}
helpText={
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/index-action-type.html#index-action-configuration`}
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.indexAction.indexDocumentHelpLabel"
defaultMessage="Index document example."
/>
</EuiLink>
}
onBlur={() => {
if (
!(documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined)
) {
// set document as empty to turn on the validation for non empty valid JSON object
onDocumentsChange('{}');
}
}}
/>
);
};

View file

@ -12,6 +12,7 @@ import {
loadActionTypes,
loadAllActions,
updateActionConnector,
executeAction,
} from './action_connector_api';
const http = httpServiceMock.createStartContract();
@ -128,3 +129,32 @@ describe('deleteActions', () => {
`);
});
});
describe('executeAction', () => {
test('should call execute API', async () => {
const id = '123';
const params = {
stringParams: 'someString',
numericParams: 123,
};
http.post.mockResolvedValueOnce({
actionId: id,
status: 'ok',
});
const result = await executeAction({ id, http, params });
expect(result).toEqual({
actionId: id,
status: 'ok',
});
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/actions/action/123/_execute",
Object {
"body": "{\\"params\\":{\\"stringParams\\":\\"someString\\",\\"numericParams\\":123}}",
},
]
`);
});
});

View file

@ -7,6 +7,7 @@
import { HttpSetup } from 'kibana/public';
import { BASE_ACTION_API_PATH } from '../constants';
import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types';
import { ActionTypeExecutorResult } from '../../../../../plugins/actions/common';
export async function loadActionTypes({ http }: { http: HttpSetup }): Promise<ActionType[]> {
return await http.get(`${BASE_ACTION_API_PATH}/list_action_types`);
@ -65,3 +66,17 @@ export async function deleteActions({
);
return { successes, errors };
}
export async function executeAction({
id,
params,
http,
}: {
id: string;
http: HttpSetup;
params: Record<string, unknown>;
}): Promise<ActionTypeExecutorResult<unknown>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, {
body: JSON.stringify({ params }),
});
}

View file

@ -0,0 +1,3 @@
.connectorEditFlyoutTabs {
margin-bottom: '-25px';
}

View file

@ -152,6 +152,6 @@ describe('connector_edit_flyout', () => {
const preconfiguredBadge = wrapper.find('[data-test-subj="preconfiguredBadge"]');
expect(preconfiguredBadge.exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="saveEditedActionButton"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="saveAndCloseEditedActionButton"]').exists()).toBeFalsy();
});
});

View file

@ -19,15 +19,21 @@ import {
EuiBetaBadge,
EuiText,
EuiLink,
EuiTabs,
EuiTab,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Option, none, some } from 'fp-ts/lib/Option';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import { TestConnectorForm } from './test_connector_form';
import { ActionConnectorTableItem, ActionConnector, IErrorObject } from '../../../types';
import { connectorReducer } from './connector_reducer';
import { updateActionConnector } from '../../lib/action_connector_api';
import { updateActionConnector, executeAction } from '../../lib/action_connector_api';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { PLUGIN } from '../../constants/plugin';
import { ActionTypeExecutorResult } from '../../../../../actions/common';
import './connector_edit_flyout.scss';
export interface ConnectorEditProps {
initialConnector: ActionConnectorTableItem;
@ -40,7 +46,6 @@ export const ConnectorEditFlyout = ({
editFlyoutVisible,
setEditFlyoutVisibility,
}: ConnectorEditProps) => {
let hasErrors = false;
const {
http,
toastNotifications,
@ -56,13 +61,26 @@ export const ConnectorEditFlyout = ({
connector: { ...initialConnector, secrets: {} },
});
const [isSaving, setIsSaving] = useState<boolean>(false);
const [selectedTab, setTab] = useState<'config' | 'test'>('config');
const [hasChanges, setHasChanges] = useState<boolean>(false);
const setConnector = (key: string, value: any) => {
dispatch({ command: { type: 'setConnector' }, payload: { key, value } });
};
const [testExecutionActionParams, setTestExecutionActionParams] = useState<
Record<string, unknown>
>({});
const [testExecutionResult, setTestExecutionResult] = useState<
Option<ActionTypeExecutorResult<unknown>>
>(none);
const [isExecutingAction, setIsExecutinAction] = useState<boolean>(false);
const closeFlyout = useCallback(() => {
setEditFlyoutVisibility(false);
setConnector('connector', { ...initialConnector, secrets: {} });
setHasChanges(false);
setTestExecutionResult(none);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditFlyoutVisibility]);
@ -71,11 +89,13 @@ export const ConnectorEditFlyout = ({
}
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
const errors = {
const errorsInConnectorConfig = {
...actionTypeModel?.validateConnector(connector).errors,
...validateBaseProperties(connector).errors,
} as IErrorObject;
hasErrors = !!Object.keys(errors).find((errorKey) => errors[errorKey].length >= 1);
const hasErrorsInConnectorConfig = !!Object.keys(errorsInConnectorConfig).find(
(errorKey) => errorsInConnectorConfig[errorKey].length >= 1
);
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await updateActionConnector({ http, connector, id: connector.id })
@ -173,6 +193,32 @@ export const ConnectorEditFlyout = ({
</EuiTitle>
);
const onExecutAction = () => {
setIsExecutinAction(true);
return executeAction({ id: connector.id, params: testExecutionActionParams, http }).then(
(result) => {
setIsExecutinAction(false);
setTestExecutionResult(some(result));
return result;
}
);
};
const onSaveClicked = async (closeAfterSave: boolean = true) => {
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
if (savedAction) {
setHasChanges(false);
if (closeAfterSave) {
closeFlyout();
}
if (reloadConnectors) {
reloadConnectors();
}
}
};
return (
<EuiFlyout onClose={closeFlyout} aria-labelledby="flyoutActionEditTitle" size="m">
<EuiFlyoutHeader hasBorder>
@ -184,40 +230,78 @@ export const ConnectorEditFlyout = ({
) : null}
<EuiFlexItem>{flyoutTitle}</EuiFlexItem>
</EuiFlexGroup>
<EuiTabs className="connectorEditFlyoutTabs">
<EuiTab
onClick={() => setTab('config')}
data-test-subj="configureConnectorTab"
isSelected={'config' === selectedTab}
>
{i18n.translate('xpack.triggersActionsUI.sections.editConnectorForm.tabText', {
defaultMessage: 'Configuration',
})}
</EuiTab>
<EuiTab
onClick={() => setTab('test')}
data-test-subj="testConnectorTab"
isSelected={'test' === selectedTab}
>
{i18n.translate('xpack.triggersActionsUI.sections.testConnectorForm.tabText', {
defaultMessage: 'Test',
})}
</EuiTab>
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!connector.isPreconfigured ? (
<ActionConnectorForm
connector={connector}
errors={errors}
actionTypeName={connector.actionType}
dispatch={dispatch}
actionTypeRegistry={actionTypeRegistry}
http={http}
docLinks={docLinks}
capabilities={capabilities}
consumer={consumer}
/>
{selectedTab === 'config' ? (
!connector.isPreconfigured ? (
<ActionConnectorForm
connector={connector}
errors={errorsInConnectorConfig}
actionTypeName={connector.actionType}
dispatch={(changes) => {
setHasChanges(true);
// if the user changes the connector, "forget" the last execution
// so the user comes back to a clean form ready to run a fresh test
setTestExecutionResult(none);
dispatch(changes);
}}
actionTypeRegistry={actionTypeRegistry}
http={http}
docLinks={docLinks}
capabilities={capabilities}
consumer={consumer}
/>
) : (
<Fragment>
<EuiText>
{i18n.translate(
'xpack.triggersActionsUI.sections.editConnectorForm.descriptionText',
{
defaultMessage: 'This connector is readonly.',
}
)}
</EuiText>
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/pre-configured-action-types-and-connectors.html`}
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.editConnectorForm.preconfiguredHelpLabel"
defaultMessage="Learn more about preconfigured connectors."
/>
</EuiLink>
</Fragment>
)
) : (
<Fragment>
<EuiText>
{i18n.translate(
'xpack.triggersActionsUI.sections.editConnectorForm.descriptionText',
{
defaultMessage: 'This connector is readonly.',
}
)}
</EuiText>
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/pre-configured-action-types-and-connectors.html`}
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.editConnectorForm.preconfiguredHelpLabel"
defaultMessage="Learn more about preconfigured connectors."
/>
</EuiLink>
</Fragment>
<TestConnectorForm
connector={connector}
executeEnabled={!hasChanges}
actionParams={testExecutionActionParams}
setActionParams={setTestExecutionActionParams}
onExecutAction={onExecutAction}
isExecutingAction={isExecutingAction}
executionResult={testExecutionResult}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
@ -232,35 +316,48 @@ export const ConnectorEditFlyout = ({
)}
</EuiButtonEmpty>
</EuiFlexItem>
{canSave && actionTypeModel && !connector.isPreconfigured ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveEditedActionButton"
type="submit"
iconType="check"
isDisabled={hasErrors}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
if (savedAction) {
closeFlyout();
if (reloadConnectors) {
reloadConnectors();
}
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween">
{canSave && actionTypeModel && !connector.isPreconfigured ? (
<Fragment>
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
data-test-subj="saveEditedActionButton"
isDisabled={hasErrorsInConnectorConfig || !hasChanges}
isLoading={isSaving || isExecutingAction}
onClick={async () => {
await onSaveClicked(false);
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.editConnectorForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveAndCloseEditedActionButton"
type="submit"
isDisabled={hasErrorsInConnectorConfig || !hasChanges}
isLoading={isSaving || isExecutingAction}
onClick={async () => {
await onSaveClicked();
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.editConnectorForm.saveAndCloseButtonLabel"
defaultMessage="Save & Close"
/>
</EuiButton>
</EuiFlexItem>
</Fragment>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>

View file

@ -0,0 +1,212 @@
/*
* 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, { lazy } from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import TestConnectorForm from './test_connector_form';
import { none, some } from 'fp-ts/lib/Option';
import { ActionConnector, ValidationResult } from '../../../types';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
const mockedActionParamsFields = lazy(async () => ({
default() {
return (
<EuiForm component="form">
<EuiFormRow label="Text field" helpText="I am some friendly help text.">
<EuiFieldText data-test-subj="testInputField" />
</EuiFormRow>
<EuiFormRow
label="Select (with no initial selection)"
labelAppend={
<EuiText size="xs">
<EuiLink>Link to some help</EuiLink>
</EuiText>
}
>
<EuiSelect
hasNoInitialSelection
options={[
{ value: 'option_one', text: 'Option one' },
{ value: 'option_two', text: 'Option two' },
{ value: 'option_three', text: 'Option three' },
]}
/>
</EuiFormRow>
</EuiForm>
);
},
}));
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
};
describe('test_connector_form', () => {
let deps: any;
let actionTypeRegistry;
beforeAll(async () => {
actionTypeRegistry = actionTypeRegistryMock.create();
const mocks = coreMock.createSetup();
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
deps = {
http: mocks.http,
toastNotifications: mocks.notifications.toasts,
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' },
actionTypeRegistry,
capabilities,
};
actionTypeRegistry.get.mockReturnValue(actionType);
});
it('renders initially as the action form and execute button and no result', async () => {
const connector = {
actionTypeId: actionType.id,
config: {},
secrets: {},
} as ActionConnector;
const wrapper = mountWithIntl(
<I18nProvider>
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
docLinks: deps!.docLinks,
}}
>
<TestConnectorForm
connector={connector}
executeEnabled={true}
actionParams={{}}
setActionParams={() => {}}
isExecutingAction={false}
onExecutAction={async () => ({
actionId: '',
status: 'ok',
})}
executionResult={none}
/>
</ActionsConnectorsContextProvider>
</I18nProvider>
);
const executeActionButton = wrapper?.find('[data-test-subj="executeActionButton"]');
expect(executeActionButton?.exists()).toBeTruthy();
expect(executeActionButton?.first().prop('isDisabled')).toBe(false);
const result = wrapper?.find('[data-test-subj="executionAwaiting"]');
expect(result?.exists()).toBeTruthy();
});
it('renders successful results', async () => {
const connector = {
actionTypeId: actionType.id,
config: {},
secrets: {},
} as ActionConnector;
const wrapper = mountWithIntl(
<I18nProvider>
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
docLinks: deps!.docLinks,
}}
>
<TestConnectorForm
connector={connector}
executeEnabled={true}
actionParams={{}}
setActionParams={() => {}}
isExecutingAction={false}
onExecutAction={async () => ({
actionId: '',
status: 'ok',
})}
executionResult={some({
actionId: '',
status: 'ok',
})}
/>
</ActionsConnectorsContextProvider>
</I18nProvider>
);
const result = wrapper?.find('[data-test-subj="executionSuccessfulResult"]');
expect(result?.exists()).toBeTruthy();
});
it('renders failure results', async () => {
const connector = {
actionTypeId: actionType.id,
config: {},
secrets: {},
} as ActionConnector;
const wrapper = mountWithIntl(
<I18nProvider>
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
docLinks: deps!.docLinks,
}}
>
<TestConnectorForm
connector={connector}
executeEnabled={true}
actionParams={{}}
setActionParams={() => {}}
isExecutingAction={false}
onExecutAction={async () => ({
actionId: '',
status: 'error',
message: 'Error Message',
})}
executionResult={some({
actionId: '',
status: 'error',
message: 'Error Message',
})}
/>
</ActionsConnectorsContextProvider>
</I18nProvider>
);
const result = wrapper?.find('[data-test-subj="executionFailureResult"]');
expect(result?.exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,224 @@
/*
* 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, { Fragment, Suspense } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiButton,
EuiSteps,
EuiLoadingSpinner,
EuiDescriptionList,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
import { Option, map, getOrElse } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ActionConnector } from '../../../types';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionTypeExecutorResult } from '../../../../../actions/common';
export interface ConnectorAddFlyoutProps {
connector: ActionConnector;
executeEnabled: boolean;
isExecutingAction: boolean;
setActionParams: (params: Record<string, unknown>) => void;
actionParams: Record<string, unknown>;
onExecutAction: () => Promise<ActionTypeExecutorResult<unknown>>;
executionResult: Option<ActionTypeExecutorResult<unknown>>;
}
export const TestConnectorForm = ({
connector,
executeEnabled,
executionResult,
actionParams,
setActionParams,
onExecutAction,
isExecutingAction,
}: ConnectorAddFlyoutProps) => {
const { actionTypeRegistry, docLinks } = useActionsConnectorsContext();
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
const ParamsFieldsComponent = actionTypeModel.actionParamsFields;
const actionErrors = actionTypeModel?.validateParams(actionParams);
const hasErrors = !!Object.values(actionErrors.errors).find((errors) => errors.length > 0);
const steps = [
{
title: 'Create an action',
children: ParamsFieldsComponent ? (
<Suspense
fallback={
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
}
>
<ParamsFieldsComponent
actionParams={actionParams}
index={0}
errors={actionErrors.errors}
editAction={(field, value) =>
setActionParams({
...actionParams,
[field]: value,
})
}
messageVariables={[]}
docLinks={docLinks}
actionConnector={connector}
/>
</Suspense>
) : (
<EuiText>
<p>This Connector does not require any Action Parameter.</p>
</EuiText>
),
},
{
title: 'Run the action',
children: (
<Fragment>
{executeEnabled ? null : (
<Fragment>
<EuiCallOut iconType="alert" color="warning">
<p>
<FormattedMessage
defaultMessage="Save your changes before testing the connector."
id="xpack.triggersActionsUI.sections.testConnectorForm.executeTestDisabled"
/>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</Fragment>
)}
<EuiText>
<EuiButton
iconType={'play'}
isLoading={isExecutingAction}
isDisabled={!executeEnabled || hasErrors || isExecutingAction}
data-test-subj="executeActionButton"
onClick={onExecutAction}
>
<FormattedMessage
defaultMessage="Run"
id="xpack.triggersActionsUI.sections.testConnectorForm.executeTestButton"
/>
</EuiButton>
</EuiText>
</Fragment>
),
},
{
title: 'Results',
children: pipe(
executionResult,
map((result) =>
result.status === 'ok' ? (
<SuccessfulExecution />
) : (
<FailedExecussion executionResult={result} />
)
),
getOrElse(() => <AwaitingExecution />)
),
},
];
return <EuiSteps steps={steps} />;
};
const AwaitingExecution = () => (
<EuiCallOut data-test-subj="executionAwaiting">
<p>
<FormattedMessage
defaultMessage="When you run the action, the results will show up here."
id="xpack.triggersActionsUI.sections.testConnectorForm.awaitingExecutionDescription"
/>
</p>
</EuiCallOut>
);
const SuccessfulExecution = () => (
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.sections.testConnectorForm.executionSuccessfulTitle',
{
defaultMessage: 'Action was successful',
values: {},
}
)}
color="success"
data-test-subj="executionSuccessfulResult"
iconType="check"
>
<p>
<FormattedMessage
defaultMessage="Ensure the results are what you expect."
id="xpack.triggersActionsUI.sections.testConnectorForm.executionSuccessfulDescription"
/>
</p>
</EuiCallOut>
);
const FailedExecussion = ({
executionResult: { message, serviceMessage },
}: {
executionResult: ActionTypeExecutorResult<unknown>;
}) => {
const items = [
{
title: i18n.translate(
'xpack.triggersActionsUI.sections.testConnectorForm.executionFailureDescription',
{
defaultMessage: 'The following error was found:',
}
),
description:
message ??
i18n.translate(
'xpack.triggersActionsUI.sections.testConnectorForm.executionFailureUnknownReason',
{
defaultMessage: 'Unknown reason',
}
),
},
];
if (serviceMessage) {
items.push({
title: i18n.translate(
'xpack.triggersActionsUI.sections.testConnectorForm.executionFailureAdditionalDetails',
{
defaultMessage: 'Details:',
}
),
description: serviceMessage,
});
}
return (
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.sections.testConnectorForm.executionFailureTitle',
{
defaultMessage: 'Action failed to run',
}
)}
data-test-subj="executionFailureResult"
color="danger"
iconType="alert"
>
<EuiDescriptionList textStyle="reverse" listItems={items} />
</EuiCallOut>
);
};
// eslint-disable-next-line import/no-default-export
export { TestConnectorForm as default };

View file

@ -194,55 +194,15 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
truncateText: true,
},
{
field: 'isPreconfigured',
name: '',
render: (value: number, item: ActionConnectorTableItem) => {
if (item.isPreconfigured) {
return (
<EuiFlexGroup justifyContent="flexEnd" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiBetaBadge
data-test-subj="preConfiguredTitleMessage"
label={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.preconfiguredTitleMessage',
{
defaultMessage: 'Preconfigured',
}
)}
tooltipContent="This connector can't be deleted."
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
render: (item: ActionConnectorTableItem) => {
return (
<EuiFlexGroup justifyContent="flexEnd" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiToolTip
content={
canDelete
? i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription',
{ defaultMessage: 'Delete this connector' }
)
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription',
{ defaultMessage: 'Unable to delete connectors' }
)
}
>
<EuiButtonIcon
isDisabled={!canDelete}
data-test-subj="deleteConnector"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName',
{ defaultMessage: 'Delete' }
)}
onClick={() => setConnectorsToDelete([item.id])}
iconType={'trash'}
/>
</EuiToolTip>
</EuiFlexItem>
<DeleteOperation
canDelete={canDelete}
item={item}
onDelete={() => setConnectorsToDelete([item.id])}
/>
</EuiFlexGroup>
);
},
@ -344,28 +304,6 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
/>
);
const noPermissionPrompt = (
<EuiEmptyPrompt
iconType="securityApp"
title={
<h1>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateTitle"
defaultMessage="No permissions to create connectors"
/>
</h1>
}
body={
<p data-test-subj="permissionDeniedMessage">
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateDescription"
defaultMessage="Contact your system administrator."
/>
</p>
}
/>
);
return (
<section data-test-subj="actionsList">
<DeleteModalConfirmation
@ -411,7 +349,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
{data.length === 0 && canSave && !isLoadingActions && !isLoadingActionTypes && (
<EmptyConnectorsPrompt onCTAClicked={() => setAddFlyoutVisibility(true)} />
)}
{data.length === 0 && !canSave && noPermissionPrompt}
{data.length === 0 && !canSave && <NoPermissionPrompt />}
<ActionsConnectorsContextProvider
value={{
actionTypeRegistry,
@ -442,3 +380,76 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
function getActionsCountByActionType(actions: ActionConnector[], actionTypeId: string) {
return actions.filter((action) => action.actionTypeId === actionTypeId).length;
}
const DeleteOperation: React.FunctionComponent<{
item: ActionConnectorTableItem;
canDelete: boolean;
onDelete: () => void;
}> = ({ item, canDelete, onDelete }) => {
if (item.isPreconfigured) {
return (
<EuiFlexItem grow={false}>
<EuiBetaBadge
data-test-subj="preConfiguredTitleMessage"
label={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.preconfiguredTitleMessage',
{
defaultMessage: 'Preconfigured',
}
)}
tooltipContent="This connector can't be deleted."
/>
</EuiFlexItem>
);
}
return (
<EuiFlexItem grow={false}>
<EuiToolTip
content={
canDelete
? i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription',
{ defaultMessage: 'Delete this connector' }
)
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription',
{ defaultMessage: 'Unable to delete connectors' }
)
}
>
<EuiButtonIcon
isDisabled={!canDelete}
data-test-subj="deleteConnector"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName',
{ defaultMessage: 'Delete' }
)}
onClick={onDelete}
iconType={'trash'}
/>
</EuiToolTip>
</EuiFlexItem>
);
};
const NoPermissionPrompt: React.FunctionComponent<{}> = () => (
<EuiEmptyPrompt
iconType="securityApp"
title={
<h1>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateTitle"
defaultMessage="No permissions to create connectors"
/>
</h1>
}
body={
<p data-test-subj="permissionDeniedMessage">
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.noPermissionToCreateDescription"
defaultMessage="Contact your system administrator."
/>
</p>
}
/>
);

View file

@ -17,6 +17,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']);
const find = getService('find');
const retry = getService('retry');
const comboBox = getService('comboBox');
describe('Connectors', function () {
before(async () => {
@ -76,7 +78,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.setValue('slackWebhookUrlInput', 'https://test');
await find.clickByCssSelector('[data-test-subj="saveEditedActionButton"]:not(disabled)');
await find.clickByCssSelector(
'[data-test-subj="saveAndCloseEditedActionButton"]:not(disabled)'
);
const toastTitle = await pageObjects.common.closeToast();
expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`);
@ -92,6 +96,64 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
]);
});
it('should test a connector and display a successful result', async () => {
const connectorName = generateUniqueKey();
const indexName = generateUniqueKey();
await createIndexConnector(connectorName, indexName);
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
await find.clickByCssSelector('[data-test-subj="testConnectorTab"]');
// test success
await testSubjects.setValue('documentsJsonEditor', '{ "key": "value" }');
await find.clickByCssSelector('[data-test-subj="executeActionButton"]:not(disabled)');
await retry.try(async () => {
await testSubjects.find('executionSuccessfulResult');
});
await find.clickByCssSelector(
'[data-test-subj="cancelSaveEditedConnectorButton"]:not(disabled)'
);
});
it('should test a connector and display a failure result', async () => {
const connectorName = generateUniqueKey();
const indexName = generateUniqueKey();
await createIndexConnector(connectorName, indexName);
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
await find.clickByCssSelector('[data-test-subj="testConnectorTab"]');
await testSubjects.setValue('documentsJsonEditor', '{ "": "value" }');
await find.clickByCssSelector('[data-test-subj="executeActionButton"]:not(disabled)');
await retry.try(async () => {
const executionFailureResultCallout = await testSubjects.find('executionFailureResult');
expect(await executionFailureResultCallout.getVisibleText()).to.match(
/error indexing documents/
);
});
await find.clickByCssSelector(
'[data-test-subj="cancelSaveEditedConnectorButton"]:not(disabled)'
);
});
it('should reset connector when canceling an edit', async () => {
const connectorName = generateUniqueKey();
await createConnector(connectorName);
@ -193,7 +255,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
expect(await testSubjects.exists('preconfiguredBadge')).to.be(true);
expect(await testSubjects.exists('saveEditedActionButton')).to.be(false);
expect(await testSubjects.exists('saveAndCloseEditedActionButton')).to.be(false);
});
});
@ -209,4 +271,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)');
await pageObjects.common.closeToast();
}
async function createIndexConnector(connectorName: string, indexName: string) {
await pageObjects.triggersActionsUI.clickCreateConnectorButton();
await testSubjects.click('.index-card');
await testSubjects.setValue('nameInput', connectorName);
await comboBox.set('connectorIndexesComboBox', indexName);
await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)');
await pageObjects.common.closeToast();
}
};