Added possibility to embed connectors create and edit flyouts (#58514)

* Added possibility to embed connectors flyout

* Fixed type checks and removed example from siem start page

* Fixed jest tests

* Fixed failing tests

* fixed type check

* Added config for siem tests

* Fixed failing tests

* Fixed due to comments

* Added missing documentation
This commit is contained in:
Yuliia Naumenko 2020-03-05 14:57:32 -08:00 committed by GitHub
parent c3f8647c3e
commit e869695d73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 539 additions and 335 deletions

View file

@ -43,6 +43,8 @@ Table of Contents
- [Action type model definition](#action-type-model-definition)
- [Register action type model](#register-action-type-model)
- [Create and register new action type UI example](#reate-and-register-new-action-type-ui-example)
- [Embed the Create Connector flyout within any Kibana plugin](#embed-the-create-connector-flyout-within-any-kibana-plugin)
- [Embed the Edit Connector flyout within any Kibana plugin](#embed-the-edit-connector-flyout-within-any-kibana-plugin)
## Built-in Alert Types
@ -667,6 +669,7 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false);
uiSettings,
charts,
dataFieldsFormats,
metadata: { test: 'some value', fields: ['test'] },
}}
>
<AlertAdd consumer={'watcher'} />
@ -690,7 +693,7 @@ interface AlertAddProps {
AlertsContextProvider value options:
```
export interface AlertsContextValue {
export interface AlertsContextValue<MetaData = Record<string, any>> {
addFlyoutVisible: boolean;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
reloadAlerts?: () => Promise<void>;
@ -704,6 +707,7 @@ export interface AlertsContextValue {
>;
charts?: ChartsPluginSetup;
dataFieldsFormats?: Pick<FieldFormatsRegistry, 'register'>;
metadata?: MetaData;
}
```
@ -719,6 +723,7 @@ export interface AlertsContextValue {
|toastNotifications|Optional toast messages.|
|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.|
|metadata|Optional generic property, which allows to define component specific metadata. This metadata can be used for passing down preloaded data for Alert type expression component.|
## Build and register Action Types
@ -1198,3 +1203,213 @@ Clicking on the select card for `Example Action Type` will open the action type
or create a new connector:
![Example Action Type with empty connectors list](https://i.imgur.com/EamA9Xv.png)
## Embed the Create Connector flyout within any Kibana plugin
Follow the instructions bellow to embed the Create Connector flyout within any Kibana plugin:
1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies:
```
import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../../../../x-pack/plugins/triggers_actions_ui/public';
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
...
triggers_actions_ui: TriggersAndActionsUIPublicPluginStart;
```
Then this dependency will be used to embed Create Connector flyout or register new action type.
2. Add Create Connector flyout to React component:
```
// import section
import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../../../../../triggers_actions_ui/public';
// in the component state definition section
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
// load required dependancied
const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services;
const connector = {
secrets: {},
id: 'test',
actionTypeId: '.index',
actionType: 'Index',
name: 'action-connector',
referencedByCount: 0,
config: {},
};
// UI control item for open flyout
<EuiButton
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setAddFlyoutVisibility(true)}
>
<FormattedMessage
id="emptyButton"
defaultMessage="Create connector"
/>
</EuiButton>
// in render section of component
<ActionsConnectorsContextProvider
value={{
http: http,
toastNotifications: toastNotifications,
actionTypeRegistry: triggers_actions_ui.actionTypeRegistry,
capabilities: capabilities,
}}
>
<ConnectorAddFlyout
addFlyoutVisible={addFlyoutVisible}
setAddFlyoutVisibility={setAddFlyoutVisibility}
actionTypes={[
{
id: '.index',
enabled: true,
name: 'Index',
},
]}
/>
</ActionsConnectorsContextProvider>
```
ConnectorAddFlyout Props definition:
```
export interface ConnectorAddFlyoutProps {
addFlyoutVisible: boolean;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
actionTypes?: ActionType[];
}
```
|Property|Description|
|---|---|
|addFlyoutVisible|Visibility state of the Create Connector flyout.|
|setAddFlyoutVisibility|Function for changing visibility state of the Create Connector flyout.|
|actionTypes|Optional property, that allows to define only specific action types list which is available for a current plugin.|
ActionsConnectorsContextValue options:
```
export interface ActionsConnectorsContextValue {
http: HttpSetup;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
capabilities: ApplicationStart['capabilities'];
reloadConnectors?: () => Promise<void>;
}
```
|Property|Description|
|---|---|
|http|HttpSetup needed for executing API calls.|
|actionTypeRegistry|Registry for action types.|
|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.|
|toastNotifications|Toast messages.|
|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.|
## Embed the Edit Connector flyout within any Kibana plugin
Follow the instructions bellow to embed the Edit Connector flyout within any Kibana plugin:
1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies:
```
import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../../../../x-pack/plugins/triggers_actions_ui/public';
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
...
triggers_actions_ui: TriggersAndActionsUIPublicPluginStart;
```
Then this dependency will be used to embed Edit Connector flyout.
2. Add Create Connector flyout to React component:
```
// import section
import { ActionsConnectorsContextProvider, ConnectorEditFlyout } from '../../../../../../../triggers_actions_ui/public';
// in the component state definition section
const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false);
// load required dependancied
const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services;
// UI control item for open flyout
<EuiButton
fill
iconType="plusInCircle"
iconSide="left"
onClick={() => setEditFlyoutVisibility(true)}
>
<FormattedMessage
id="emptyButton"
defaultMessage="Edit connector"
/>
</EuiButton>
// in render section of component
<ActionsConnectorsContextProvider
value={{
http: http,
toastNotifications: toastNotifications,
actionTypeRegistry: triggers_actions_ui.actionTypeRegistry,
capabilities: capabilities,
}}
>
<ConnectorEditFlyout
initialConnector={connector}
editFlyoutVisible={editFlyoutVisible}
setEditFlyoutVisibility={setEditFlyoutVisibility}
/>
</ActionsConnectorsContextProvider>
```
ConnectorEditFlyout Props definition:
```
export interface ConnectorEditProps {
initialConnector: ActionConnectorTableItem;
editFlyoutVisible: boolean;
setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
}
```
|Property|Description|
|---|---|
|initialConnector|Property, that allows to define the initial state of edited connector.|
|editFlyoutVisible|Visibility state of the Edit Connector flyout.|
|setEditFlyoutVisibility|Function for changing visibility state of the Edit Connector flyout.|
ActionsConnectorsContextValue options:
```
export interface ActionsConnectorsContextValue {
http: HttpSetup;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
capabilities: ApplicationStart['capabilities'];
reloadConnectors?: () => Promise<void>;
}
```
|Property|Description|
|---|---|
|http|HttpSetup needed for executing API calls.|
|actionTypeRegistry|Registry for action types.|
|capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.|
|toastNotifications|Toast messages.|
|reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.|

View file

@ -5,15 +5,19 @@
*/
import React, { createContext, useContext } from 'react';
import { ActionType } from '../../types';
import { HttpSetup, ToastsApi, ApplicationStart } from 'kibana/public';
import { ActionTypeModel } from '../../types';
import { TypeRegistry } from '../type_registry';
export interface ActionsConnectorsContextValue {
addFlyoutVisible: boolean;
editFlyoutVisible: boolean;
setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
actionTypesIndex: Record<string, ActionType> | undefined;
reloadConnectors: () => Promise<void>;
http: HttpSetup;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
capabilities: ApplicationStart['capabilities'];
reloadConnectors?: () => Promise<void>;
}
const ActionsConnectorsContext = createContext<ActionsConnectorsContextValue>(null as any);

View file

@ -9,26 +9,21 @@ import { coreMock } from '../../../../../../../src/core/public/mocks';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, ActionConnector } from '../../../types';
import { ActionConnectorForm } from './action_connector_form';
import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('action_connector_form', () => {
let deps: any;
let deps: ActionsConnectorsContextValue;
beforeAll(async () => {
const mocks = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mocks.getStartServices();
deps = {
chrome,
docLinks,
toastNotifications: mocks.notifications.toasts,
injectedMetadata: mocks.injectedMetadata,
http: mocks.http,
uiSettings: mocks.uiSettings,
capabilities: {
...capabilities,
actions: {
@ -37,11 +32,7 @@ describe('action_connector_form', () => {
show: true,
},
},
legacy: {
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});

View file

@ -6,31 +6,28 @@
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import {
ActionsConnectorsContextProvider,
ActionsConnectorsContextValue,
} from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ActionTypeMenu } from './action_type_menu';
import { ValidationResult } from '../../../types';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('connector_add_flyout', () => {
let deps: any;
let deps: ActionsConnectorsContextValue;
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mockes.getStartServices();
deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
toastNotifications: mockes.notifications.toasts,
capabilities: {
...capabilities,
actions: {
@ -39,11 +36,7 @@ describe('connector_add_flyout', () => {
show: true,
},
},
legacy: {
MANAGEMENT_BREADCRUMB: { set: () => {} } as any,
},
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});
@ -68,14 +61,10 @@ describe('connector_add_flyout', () => {
const wrapper = mountWithIntl(
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'first-action-type': { id: 'first-action-type', name: 'first', enabled: true },
'second-action-type': { id: 'second-action-type', name: 'second', enabled: true },
},
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
@ -83,12 +72,17 @@ describe('connector_add_flyout', () => {
>
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypeRegistry={deps.actionTypeRegistry}
actionTypes={[
{
id: actionType.id,
enabled: true,
name: 'Test',
},
]}
/>
</ActionsConnectorsContextProvider>
);
expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy();
});
});

View file

@ -3,24 +3,46 @@
* 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 React, { useEffect, useState } from 'react';
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui';
import { ActionType, ActionTypeModel } from '../../../types';
import { i18n } from '@kbn/i18n';
import { ActionType, ActionTypeIndex } from '../../../types';
import { loadActionTypes } from '../../lib/action_connector_api';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { TypeRegistry } from '../../type_registry';
interface Props {
onActionTypeChange: (actionType: ActionType) => void;
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
actionTypes?: ActionType[];
}
export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props) => {
const { actionTypesIndex } = useActionsConnectorsContext();
if (!actionTypesIndex) {
return null;
}
export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext();
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
const actionTypes = Object.entries(actionTypesIndex)
useEffect(() => {
(async () => {
try {
const availableActionTypes = actionTypes ?? (await loadActionTypes({ http }));
const index: ActionTypeIndex = {};
for (const actionTypeItem of availableActionTypes) {
index[actionTypeItem.id] = actionTypeItem;
}
setActionTypesIndex(index);
} catch (e) {
if (toastNotifications) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage',
{ defaultMessage: 'Unable to load action types' }
),
});
}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const registeredActionTypes = Object.entries(actionTypesIndex ?? [])
.filter(([index]) => actionTypeRegistry.has(index))
.map(([index, actionType]) => {
const actionTypeModel = actionTypeRegistry.get(index);
@ -33,7 +55,7 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypeRegistry }: Props
};
});
const cardNodes = actionTypes
const cardNodes = registeredActionTypes
.sort((a, b) => a.name.localeCompare(b.name))
.map((item, index) => {
return (

View file

@ -7,37 +7,28 @@ import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { ConnectorAddFlyout } from './connector_add_flyout';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import {
ActionsConnectorsContextProvider,
ActionsConnectorsContextValue,
} from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { AppContextProvider } from '../../app_context';
import { AppDeps } from '../../app';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('connector_add_flyout', () => {
let deps: AppDeps | null;
let deps: ActionsConnectorsContextValue;
beforeAll(async () => {
const mocks = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mocks.getStartServices();
deps = {
chrome,
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
toastNotifications: mocks.notifications.toasts,
injectedMetadata: mocks.injectedMetadata,
http: mocks.http,
uiSettings: mocks.uiSettings,
capabilities: {
...capabilities,
actions: {
@ -46,9 +37,7 @@ describe('connector_add_flyout', () => {
show: true,
},
},
setBreadcrumbs: jest.fn(),
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});
@ -71,24 +60,29 @@ describe('connector_add_flyout', () => {
actionTypeRegistry.has.mockReturnValue(true);
const wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'my-action-type': { id: 'my-action-type', name: 'test', enabled: true },
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
toastNotifications: deps!.toastNotifications,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorAddFlyout
addFlyoutVisible={true}
setAddFlyoutVisibility={() => {}}
actionTypes={[
{
id: actionType.id,
enabled: true,
name: 'Test',
},
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorAddFlyout />
</ActionsConnectorsContextProvider>
</AppContextProvider>
]}
/>
</ActionsConnectorsContextProvider>
);
expect(wrapper.find('ActionTypeMenu')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy();

View file

@ -20,18 +20,33 @@ import {
EuiBetaBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionTypeMenu } from './action_type_menu';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import { ActionType, ActionConnector, IErrorObject } from '../../../types';
import { useAppDependencies } from '../../app_context';
import { connectorReducer } from './connector_reducer';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { createActionConnector } from '../../lib/action_connector_api';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
export const ConnectorAddFlyout = () => {
export interface ConnectorAddFlyoutProps {
addFlyoutVisible: boolean;
setAddFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
actionTypes?: ActionType[];
}
export const ConnectorAddFlyout = ({
addFlyoutVisible,
setAddFlyoutVisibility,
actionTypes,
}: ConnectorAddFlyoutProps) => {
let hasErrors = false;
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
const {
http,
toastNotifications,
capabilities,
actionTypeRegistry,
reloadConnectors,
} = useActionsConnectorsContext();
const [actionType, setActionType] = useState<ActionType | undefined>(undefined);
// hooks
@ -48,11 +63,6 @@ export const ConnectorAddFlyout = () => {
dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } });
};
const {
addFlyoutVisible,
setAddFlyoutVisibility,
reloadConnectors,
} = useActionsConnectorsContext();
const [isSaving, setIsSaving] = useState<boolean>(false);
const closeFlyout = useCallback(() => {
@ -79,10 +89,7 @@ export const ConnectorAddFlyout = () => {
let actionTypeModel;
if (!actionType) {
currentForm = (
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypeRegistry={actionTypeRegistry}
/>
<ActionTypeMenu onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} />
);
} else {
actionTypeModel = actionTypeRegistry.get(actionType.id);
@ -108,17 +115,19 @@ export const ConnectorAddFlyout = () => {
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await createActionConnector({ http, connector })
.then(savedConnector => {
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Created '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
if (toastNotifications) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Created '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
}
return savedConnector;
})
.catch(errorRes => {
@ -218,7 +227,9 @@ export const ConnectorAddFlyout = () => {
setIsSaving(false);
if (savedAction) {
closeFlyout();
reloadConnectors();
if (reloadConnectors) {
reloadConnectors();
}
}
}}
>

View file

@ -7,35 +7,24 @@ import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { ConnectorAddModal } from './connector_add_modal';
import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { AppDeps } from '../../app';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('connector_add_modal', () => {
let deps: AppDeps | null;
let deps: ActionsConnectorsContextValue;
beforeAll(async () => {
const mocks = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mocks.getStartServices();
deps = {
chrome,
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
toastNotifications: mocks.notifications.toasts,
injectedMetadata: mocks.injectedMetadata,
http: mocks.http,
uiSettings: mocks.uiSettings,
capabilities: {
...capabilities,
actions: {
@ -44,9 +33,7 @@ describe('connector_add_modal', () => {
show: true,
},
},
setBreadcrumbs: jest.fn(),
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
});
it('renders connector modal form if addModalVisible is true', () => {
@ -75,30 +62,15 @@ describe('connector_add_modal', () => {
const wrapper = deps
? mountWithIntl(
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: true,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: false,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'my-action-type': { id: 'my-action-type', name: 'test', enabled: true },
},
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorAddModal
addModalVisible={true}
setAddModalVisibility={() => {}}
actionType={actionType}
http={deps.http}
actionTypeRegistry={deps.actionTypeRegistry}
alertTypeRegistry={deps.alertTypeRegistry}
toastNotifications={deps.toastNotifications}
/>
</ActionsConnectorsContextProvider>
<ConnectorAddModal
addModalVisible={true}
setAddModalVisibility={() => {}}
actionType={actionType}
http={deps.http}
actionTypeRegistry={deps.actionTypeRegistry}
alertTypeRegistry={{} as any}
toastNotifications={deps.toastNotifications}
/>
)
: undefined;
expect(wrapper?.find('EuiModalHeader')).toHaveLength(1);

View file

@ -11,8 +11,6 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { ConnectorEditFlyout } from './connector_edit_flyout';
import { AppContextProvider } from '../../app_context';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
const actionTypeRegistry = actionTypeRegistryMock.create();
let deps: any;
@ -22,18 +20,11 @@ describe('connector_edit_flyout', () => {
const mockes = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mockes.getStartServices();
deps = {
chrome,
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
capabilities: {
@ -44,7 +35,6 @@ describe('connector_edit_flyout', () => {
show: true,
},
},
setBreadcrumbs: jest.fn(),
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: {} as any,
};
@ -82,19 +72,20 @@ describe('connector_edit_flyout', () => {
<AppContextProvider appDeps={deps}>
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible: false,
setAddFlyoutVisibility: state => {},
editFlyoutVisible: true,
setEditFlyoutVisibility: state => {},
actionTypesIndex: {
'test-action-type-id': { id: 'test-action-type-id', name: 'test', enabled: true },
},
http: deps.http,
toastNotifications: deps.toastNotifications,
capabilities: deps.capabilities,
actionTypeRegistry: deps.actionTypeRegistry,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ConnectorEditFlyout initialConnector={connector} />
<ConnectorEditFlyout
initialConnector={connector}
editFlyoutVisible={true}
setEditFlyoutVisibility={state => {}}
/>
</ActionsConnectorsContextProvider>
</AppContextProvider>
);

View file

@ -19,27 +19,33 @@ import {
EuiBetaBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import { useAppDependencies } from '../../app_context';
import { ActionConnectorTableItem, ActionConnector, IErrorObject } from '../../../types';
import { connectorReducer } from './connector_reducer';
import { updateActionConnector } from '../../lib/action_connector_api';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
export interface ConnectorEditProps {
initialConnector: ActionConnectorTableItem;
editFlyoutVisible: boolean;
setEditFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
}
export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => {
export const ConnectorEditFlyout = ({
initialConnector,
editFlyoutVisible,
setEditFlyoutVisibility,
}: ConnectorEditProps) => {
let hasErrors = false;
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
const canSave = hasSaveActionsCapability(capabilities);
const {
editFlyoutVisible,
setEditFlyoutVisibility,
http,
toastNotifications,
capabilities,
actionTypeRegistry,
reloadConnectors,
} = useActionsConnectorsContext();
const canSave = hasSaveActionsCapability(capabilities);
const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]);
const [{ connector }, dispatch] = useReducer(connectorReducer, {
connector: { ...initialConnector, secrets: {} },
@ -63,17 +69,19 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) =>
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await updateActionConnector({ http, connector, id: connector.id })
.then(savedConnector => {
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Updated '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
if (toastNotifications) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Updated '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
}
return savedConnector;
})
.catch(errorRes => {
@ -151,7 +159,9 @@ export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) =>
setIsSaving(false);
if (savedAction) {
closeFlyout();
reloadConnectors();
if (reloadConnectors) {
reloadConnectors();
}
}
}}
>

View file

@ -18,16 +18,16 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context';
import { useAppDependencies } from '../../../app_context';
import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api';
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form';
import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities';
import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal';
import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context';
export const ActionsConnectorsList: React.FunctionComponent = () => {
const { http, toastNotifications, capabilities } = useAppDependencies();
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
const canDelete = hasDeleteActionsCapability(capabilities);
const canSave = hasSaveActionsCapability(capabilities);
@ -377,19 +377,23 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
{data.length === 0 && !canSave && noPermissionPrompt}
<ActionsConnectorsContextProvider
value={{
addFlyoutVisible,
setAddFlyoutVisibility,
editFlyoutVisible,
setEditFlyoutVisibility,
actionTypesIndex,
actionTypeRegistry,
http,
capabilities,
toastNotifications,
reloadConnectors: loadActions,
}}
>
<ConnectorAddFlyout />
<ConnectorAddFlyout
addFlyoutVisible={addFlyoutVisible}
setAddFlyoutVisibility={setAddFlyoutVisibility}
/>
{editedConnectorItem ? (
<ConnectorEditFlyout
key={editedConnectorItem.id}
initialConnector={editedConnectorItem}
editFlyoutVisible={editFlyoutVisible}
setEditFlyoutVisibility={setEditFlyoutVisibility}
/>
) : null}
</ActionsConnectorsContextProvider>

View file

@ -20,7 +20,7 @@ describe('alert_edit', () => {
let deps: any;
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
async function setup() {
const mockes = coreMock.createSetup();
deps = {
toastNotifications: mockes.notifications.toasts,
@ -122,9 +122,10 @@ describe('alert_edit', () => {
await nextTick();
wrapper.update();
});
});
}
it('renders alert add flyout', () => {
it('renders alert add flyout', async () => {
await setup();
expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy();
});

View file

@ -4,22 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { ValidationResult, Alert } from '../../../types';
import { AlertForm } from './alert_form';
import { AppDeps } from '../../app';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { AlertsContextProvider } from '../../context/alerts_context';
import { coreMock } from 'src/core/public/mocks';
const actionTypeRegistry = actionTypeRegistryMock.create();
const alertTypeRegistry = alertTypeRegistryMock.create();
describe('alert_form', () => {
let deps: AppDeps | null;
let deps: any;
const alertType = {
id: 'my-alert-type',
iconClass: 'test',
@ -44,42 +41,19 @@ describe('alert_form', () => {
actionConnectorFields: null,
actionParamsFields: null,
};
beforeAll(async () => {
const mockes = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities },
},
] = await mockes.getStartServices();
deps = {
chrome,
docLinks,
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
capabilities: {
...capabilities,
siem: {
'alerting:show': true,
'alerting:save': true,
'alerting:delete': false,
},
},
setBreadcrumbs: jest.fn(),
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
});
describe('alert_form create alert', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
async function setup() {
const mockes = coreMock.createSetup();
deps = {
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
alertTypeRegistry.list.mockReturnValue([alertType]);
alertTypeRegistry.has.mockReturnValue(true);
actionTypeRegistry.list.mockReturnValue([actionType]);
@ -99,47 +73,49 @@ describe('alert_form', () => {
mutedInstanceIds: [],
} as unknown) as Alert;
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,
uiSettings: deps!.uiSettings,
}}
>
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [] }}
serverError={null}
/>
</AlertsContextProvider>
);
await act(async () => {
if (deps) {
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps.http,
actionTypeRegistry: deps.actionTypeRegistry,
alertTypeRegistry: deps.alertTypeRegistry,
toastNotifications: deps.toastNotifications,
uiSettings: deps.uiSettings,
}}
>
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [] }}
serverError={null}
/>
</AlertsContextProvider>
);
}
await nextTick();
wrapper.update();
});
}
await waitForRender(wrapper);
});
it('renders alert name', () => {
it('renders alert name', async () => {
await setup();
const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]');
expect(alertNameField.exists()).toBeTruthy();
expect(alertNameField.first().prop('value')).toBe('test');
});
it('renders registered selected alert type', () => {
it('renders registered selected alert type', async () => {
await setup();
const alertTypeSelectOptions = wrapper.find('[data-test-subj="my-alert-type-SelectOption"]');
expect(alertTypeSelectOptions.exists()).toBeTruthy();
});
it('renders registered action types', () => {
it('renders registered action types', async () => {
await setup();
const alertTypeSelectOptions = wrapper.find(
'[data-test-subj=".server-log-ActionTypeSelectOption"]'
);
@ -150,7 +126,15 @@ describe('alert_form', () => {
describe('alert_form edit alert', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
async function setup() {
const mockes = coreMock.createSetup();
deps = {
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
actionTypeRegistry: actionTypeRegistry as any,
alertTypeRegistry: alertTypeRegistry as any,
};
alertTypeRegistry.list.mockReturnValue([alertType]);
alertTypeRegistry.get.mockReturnValue(alertType);
alertTypeRegistry.has.mockReturnValue(true);
@ -173,57 +157,53 @@ describe('alert_form', () => {
mutedInstanceIds: [],
} as unknown) as Alert;
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
alertTypeRegistry: deps!.alertTypeRegistry,
toastNotifications: deps!.toastNotifications,
uiSettings: deps!.uiSettings,
}}
>
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [] }}
serverError={null}
/>
</AlertsContextProvider>
);
await act(async () => {
if (deps) {
wrapper = mountWithIntl(
<AlertsContextProvider
value={{
reloadAlerts: () => {
return new Promise<void>(() => {});
},
http: deps.http,
actionTypeRegistry: deps.actionTypeRegistry,
alertTypeRegistry: deps.alertTypeRegistry,
toastNotifications: deps.toastNotifications,
uiSettings: deps.uiSettings,
}}
>
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [] }}
serverError={null}
/>
</AlertsContextProvider>
);
}
await nextTick();
wrapper.update();
});
}
await waitForRender(wrapper);
});
it('renders alert name', () => {
it('renders alert name', async () => {
await setup();
const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]');
expect(alertNameField.exists()).toBeTruthy();
expect(alertNameField.first().prop('value')).toBe('test');
});
it('renders registered selected alert type', () => {
it('renders registered selected alert type', async () => {
await setup();
const alertTypeSelectOptions = wrapper.find('[data-test-subj="selectedAlertTypeTitle"]');
expect(alertTypeSelectOptions.exists()).toBeTruthy();
});
it('renders registered action types', () => {
it('renders registered action types', async () => {
await setup();
const actionTypeSelectOptions = wrapper.find(
'[data-test-subj="my-action-type-ActionTypeSelectOption"]'
);
expect(actionTypeSelectOptions.exists()).toBeTruthy();
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}
});

View file

@ -8,7 +8,12 @@ import { PluginInitializerContext } from 'src/core/public';
import { Plugin } from './plugin';
export { AlertsContextProvider } from './application/context/alerts_context';
export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context';
export { AlertAdd } from './application/sections/alert_form';
export {
ConnectorAddFlyout,
ConnectorEditFlyout,
} from './application/sections/action_connector_form';
export function plugin(ctx: PluginInitializerContext) {
return new Plugin(ctx);

View file

@ -22,7 +22,10 @@ export interface TriggersAndActionsUIPublicPluginSetup {
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
}
export type Start = void;
export interface TriggersAndActionsUIPublicPluginStart {
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
alertTypeRegistry: TypeRegistry<AlertTypeModel>;
}
interface PluginsStart {
data: DataPublicPluginStart;
@ -30,7 +33,9 @@ interface PluginsStart {
management: ManagementStart;
}
export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup, Start> {
export class Plugin
implements
CorePlugin<TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart> {
private actionTypeRegistry: TypeRegistry<ActionTypeModel>;
private alertTypeRegistry: TypeRegistry<AlertTypeModel>;
@ -57,44 +62,46 @@ export class Plugin implements CorePlugin<TriggersAndActionsUIPublicPluginSetup,
};
}
public start(core: CoreStart, plugins: PluginsStart) {
public start(core: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart {
const { capabilities } = core.application;
const canShowActions = hasShowActionsCapability(capabilities);
const canShowAlerts = hasShowAlertsCapability(capabilities);
// Don't register routes when user doesn't have access to the application
if (!canShowActions && !canShowAlerts) {
return;
if (canShowActions || canShowAlerts) {
plugins.management.sections.getSection('kibana')!.registerApp({
id: 'triggersActions',
title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', {
defaultMessage: 'Alerts and Actions',
}),
order: 7,
mount: params => {
boot({
dataPlugin: plugins.data,
charts: plugins.charts,
element: params.element,
toastNotifications: core.notifications.toasts,
injectedMetadata: core.injectedMetadata,
http: core.http,
uiSettings: core.uiSettings,
docLinks: core.docLinks,
chrome: core.chrome,
savedObjects: core.savedObjects.client,
I18nContext: core.i18n.Context,
capabilities: core.application.capabilities,
setBreadcrumbs: params.setBreadcrumbs,
actionTypeRegistry: this.actionTypeRegistry,
alertTypeRegistry: this.alertTypeRegistry,
});
return () => {};
},
});
}
plugins.management.sections.getSection('kibana')!.registerApp({
id: 'triggersActions',
title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', {
defaultMessage: 'Alerts and Actions',
}),
order: 7,
mount: params => {
boot({
dataPlugin: plugins.data,
charts: plugins.charts,
element: params.element,
toastNotifications: core.notifications.toasts,
injectedMetadata: core.injectedMetadata,
http: core.http,
uiSettings: core.uiSettings,
docLinks: core.docLinks,
chrome: core.chrome,
savedObjects: core.savedObjects.client,
I18nContext: core.i18n.Context,
capabilities: core.application.capabilities,
setBreadcrumbs: params.setBreadcrumbs,
actionTypeRegistry: this.actionTypeRegistry,
alertTypeRegistry: this.alertTypeRegistry,
});
return () => {};
},
});
return {
actionTypeRegistry: this.actionTypeRegistry,
alertTypeRegistry: this.alertTypeRegistry,
};
}
public stop() {}

View file

@ -60,7 +60,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.click('thresholdAlertTimeFieldSelect');
const fieldOptions = await find.allByCssSelector('#thresholdTimeField option');
await fieldOptions[1].click();
// need this two out of popup clicks to close them
await nameInput.click();
await testSubjects.click('intervalInput');
await testSubjects.click('.slack-ActionTypeSelectOption');
await testSubjects.click('createActionConnectorButton');
const connectorNameInput = await testSubjects.find('nameInput');