mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[RAM] Slack api allowed channels (#167150)
## Summary We add a lot of issue with our slack API by trying to fetch the channels, so we decided to only use channel ID and create an allowed list with channel IDs. Here the new design: # Connector Creation #### Creation new connector <img width="789" alt="image" src="058667a0
-5c93-4647-9f72-999008c410de"> #### Editing new connector <img width="788" alt="image" src="be2bfcb4
-6fdd-4036-82a2-e5d2d50933f6"> #### Editing old slack api connector <img width="788" alt="image" src="aae32391
-c69d-4947-8f02-4207112e2588"> # Slack Action Form #### Creation of new slack api action in rule form <img width="582" alt="image" src="1389b6de
-b549-44ff-88f6-f4cc9475198a"> #### Editing an old slack api action <img width="560" alt="image" src="a208ca97
-1fe3-4301-9a2c-96807db846bf"> <img width="570" alt="image" src="9fe019e7
-4833-480b-9149-49aa618f6d74"> ### Checklist Delete any items that are not applicable to this PR. - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Lisa Cawley <lcawley@elastic.co>
This commit is contained in:
parent
853d9e6e9f
commit
2256a7a80d
25 changed files with 1150 additions and 360 deletions
|
@ -11,14 +11,30 @@ export const SlackApiSecretsSchema = schema.object({
|
|||
token: schema.string({ minLength: 1 }),
|
||||
});
|
||||
|
||||
export const SlackApiConfigSchema = schema.object({}, { defaultValue: {} });
|
||||
export const SlackApiConfigSchema = schema.object({
|
||||
allowedChannels: schema.maybe(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
id: schema.string({ minLength: 1 }),
|
||||
name: schema.string({ minLength: 1 }),
|
||||
}),
|
||||
{ maxSize: 25 }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
export const GetChannelsParamsSchema = schema.object({
|
||||
subAction: schema.literal('getChannels'),
|
||||
export const ValidChannelIdSubActionParamsSchema = schema.object({
|
||||
channelId: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const ValidChannelIdParamsSchema = schema.object({
|
||||
subAction: schema.literal('validChannelId'),
|
||||
subActionParams: ValidChannelIdSubActionParamsSchema,
|
||||
});
|
||||
|
||||
export const PostMessageSubActionParamsSchema = schema.object({
|
||||
channels: schema.arrayOf(schema.string(), { maxSize: 1 }),
|
||||
channels: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 1 })),
|
||||
channelIds: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 1 })),
|
||||
text: schema.string({ minLength: 1 }),
|
||||
});
|
||||
|
||||
|
@ -28,6 +44,6 @@ export const PostMessageParamsSchema = schema.object({
|
|||
});
|
||||
|
||||
export const SlackApiParamsSchema = schema.oneOf([
|
||||
GetChannelsParamsSchema,
|
||||
ValidChannelIdParamsSchema,
|
||||
PostMessageParamsSchema,
|
||||
]);
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
SlackApiSecretsSchema,
|
||||
SlackApiParamsSchema,
|
||||
SlackApiConfigSchema,
|
||||
ValidChannelIdSubActionParamsSchema,
|
||||
} from './schema';
|
||||
|
||||
export type SlackApiSecrets = TypeOf<typeof SlackApiSecretsSchema>;
|
||||
|
@ -22,6 +23,7 @@ export type SlackApiConfig = TypeOf<typeof SlackApiConfigSchema>;
|
|||
|
||||
export type PostMessageParams = TypeOf<typeof PostMessageParamsSchema>;
|
||||
export type PostMessageSubActionParams = TypeOf<typeof PostMessageSubActionParamsSchema>;
|
||||
export type ValidChannelIdSubActionParams = TypeOf<typeof ValidChannelIdSubActionParamsSchema>;
|
||||
export type SlackApiParams = TypeOf<typeof SlackApiParamsSchema>;
|
||||
export type SlackApiConnectorType = ConnectorType<
|
||||
SlackApiConfig,
|
||||
|
@ -55,25 +57,34 @@ export interface SlackAPiResponse {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChannelsResponse {
|
||||
export interface ChannelResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
is_channel: boolean;
|
||||
is_archived: boolean;
|
||||
is_private: boolean;
|
||||
}
|
||||
export interface GetChannelsResponse extends SlackAPiResponse {
|
||||
channels?: ChannelsResponse[];
|
||||
|
||||
export interface ValidChannelResponse extends SlackAPiResponse {
|
||||
channel?: ChannelResponse;
|
||||
}
|
||||
|
||||
export interface PostMessageResponse extends SlackAPiResponse {
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface ValidChannelRouteResponse {
|
||||
validChannels: Array<{ id: string; name: string }>;
|
||||
invalidChannels: string[];
|
||||
}
|
||||
|
||||
export interface SlackApiService {
|
||||
getChannels: () => Promise<ConnectorTypeExecutorResult<GetChannelsResponse | void>>;
|
||||
validChannelId: (
|
||||
channelId: string
|
||||
) => Promise<ConnectorTypeExecutorResult<ValidChannelResponse | void>>;
|
||||
postMessage: ({
|
||||
channels,
|
||||
channelIds,
|
||||
text,
|
||||
}: PostMessageSubActionParams) => Promise<ConnectorTypeExecutorResult<unknown>>;
|
||||
}
|
||||
|
|
|
@ -174,6 +174,6 @@ describe('ActionForm - Slack API Connector', () => {
|
|||
</IntlProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Channel is required.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Channel ID is required.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -44,6 +44,20 @@ describe('Slack action params validation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should succeed when action params include valid message and channels and channel ids', async () => {
|
||||
const actionParams = {
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channels: ['general'], channelIds: ['general'], text: 'some text' },
|
||||
};
|
||||
|
||||
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
text: [],
|
||||
channels: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should fail when channels field is missing in action params', async () => {
|
||||
const actionParams = {
|
||||
subAction: 'postMessage',
|
||||
|
@ -53,7 +67,7 @@ describe('Slack action params validation', () => {
|
|||
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
text: [],
|
||||
channels: ['Channel is required.'],
|
||||
channels: ['Channel ID is required.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,6 +26,17 @@ import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants';
|
|||
import { SlackActionParams } from '../types';
|
||||
import { subtype } from '../slack/slack';
|
||||
|
||||
const isChannelValid = (channels?: string[], channelIds?: string[]) => {
|
||||
if (
|
||||
(channels === undefined && !channelIds?.length) ||
|
||||
(channelIds === undefined && !channels?.length) ||
|
||||
(!channelIds?.length && !channels?.length)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getConnectorType = (): ConnectorTypeModel<
|
||||
SlackApiConfig,
|
||||
SlackApiSecrets,
|
||||
|
@ -50,7 +61,12 @@ export const getConnectorType = (): ConnectorTypeModel<
|
|||
if (!actionParams.subActionParams.text) {
|
||||
errors.text.push(MESSAGE_REQUIRED);
|
||||
}
|
||||
if (!actionParams.subActionParams.channels?.length) {
|
||||
if (
|
||||
!isChannelValid(
|
||||
actionParams.subActionParams.channels,
|
||||
actionParams.subActionParams.channelIds
|
||||
)
|
||||
) {
|
||||
errors.channels.push(CHANNEL_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,15 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, fireEvent, screen } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils';
|
||||
import SlackActionFields from './slack_connectors';
|
||||
import { useFetchChannels } from './use_fetch_channels';
|
||||
import { SlackActionFieldsComponents as SlackActionFields } from './slack_connectors';
|
||||
import { useValidChannels } from './use_valid_channels';
|
||||
|
||||
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
|
||||
jest.mock('./use_fetch_channels');
|
||||
jest.mock('./use_valid_channels');
|
||||
|
||||
(useKibana as jest.Mock).mockImplementation(() => ({
|
||||
services: {
|
||||
|
@ -32,15 +32,19 @@ jest.mock('./use_fetch_channels');
|
|||
},
|
||||
}));
|
||||
|
||||
(useFetchChannels as jest.Mock).mockImplementation(() => ({
|
||||
channels: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
const useValidChannelsMock = useValidChannels as jest.Mock;
|
||||
|
||||
describe('SlackActionFields renders', () => {
|
||||
const onSubmit = jest.fn();
|
||||
beforeEach(() => {
|
||||
useValidChannelsMock.mockClear();
|
||||
onSubmit.mockClear();
|
||||
jest.clearAllMocks();
|
||||
useValidChannelsMock.mockImplementation(() => ({
|
||||
channels: [],
|
||||
isLoading: false,
|
||||
resetChannelsToValidate: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it('all connector fields is rendered for web_api type', async () => {
|
||||
|
@ -77,27 +81,68 @@ describe('SlackActionFields renders', () => {
|
|||
isDeprecated: false,
|
||||
};
|
||||
|
||||
// Simulate that user just type a channel ID
|
||||
useValidChannelsMock.mockImplementation(() => ({
|
||||
channels: ['my-channel'],
|
||||
isLoading: false,
|
||||
resetChannelsToValidate: jest.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
|
||||
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
await waitForComponentToUpdate();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('form-test-provide-submit'));
|
||||
act(() => {
|
||||
screen.getByTestId('form-test-provide-submit').click();
|
||||
});
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
data: {
|
||||
secrets: {
|
||||
token: 'some token',
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
data: {
|
||||
secrets: {
|
||||
token: 'some token',
|
||||
},
|
||||
config: {
|
||||
allowedChannels: ['my-channel'],
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.slack',
|
||||
name: 'slack',
|
||||
isDeprecated: false,
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.slack',
|
||||
name: 'slack',
|
||||
isDeprecated: false,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('connector validation should failed when allowedChannels is empty', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
token: 'some token',
|
||||
},
|
||||
isValid: true,
|
||||
id: 'test',
|
||||
actionTypeId: '.slack',
|
||||
name: 'slack',
|
||||
config: {},
|
||||
isDeprecated: false,
|
||||
};
|
||||
|
||||
render(
|
||||
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
|
||||
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
await waitForComponentToUpdate();
|
||||
act(() => {
|
||||
screen.getByTestId('form-test-provide-submit').click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
expect(onSubmit).toBeCalledWith(expect.objectContaining({ isValid: false }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionConnectorFieldsProps,
|
||||
ConfigFieldSchema,
|
||||
|
@ -13,12 +13,18 @@ import {
|
|||
SimpleConnectorForm,
|
||||
useKibana,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { EuiComboBoxOptionOption, EuiLink } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { DocLinksStart } from '@kbn/core/public';
|
||||
|
||||
import { useFormContext, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { debounce, isEmpty, isEqual } from 'lodash';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as i18n from './translations';
|
||||
import { useValidChannels } from './use_valid_channels';
|
||||
|
||||
/** wait this many ms after the user completes typing before applying the filter input */
|
||||
const INPUT_TIMEOUT = 250;
|
||||
|
||||
const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => [
|
||||
{
|
||||
|
@ -36,20 +42,141 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] =>
|
|||
},
|
||||
];
|
||||
|
||||
const NO_SCHEMA: ConfigFieldSchema[] = [];
|
||||
interface AllowedChannels {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const getConfigFormSchemaAfterSecrets = ({
|
||||
options,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
onChange,
|
||||
onCreateOption,
|
||||
selectedOptions,
|
||||
}: {
|
||||
options: Array<EuiComboBoxOptionOption<AllowedChannels>>;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
onChange: (options: Array<EuiComboBoxOptionOption<AllowedChannels>>) => void;
|
||||
onCreateOption: (searchValue: string, options: EuiComboBoxOptionOption[]) => void;
|
||||
selectedOptions: Array<EuiComboBoxOptionOption<AllowedChannels>>;
|
||||
}): ConfigFieldSchema[] => [
|
||||
{
|
||||
id: 'allowedChannels',
|
||||
isRequired: true,
|
||||
label: i18n.ALLOWED_CHANNELS,
|
||||
type: 'COMBO_BOX',
|
||||
euiFieldProps: {
|
||||
noSuggestions: true,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
options,
|
||||
onChange,
|
||||
onCreateOption,
|
||||
selectedOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const NO_SCHEMA: ConfigFieldSchema[] = [];
|
||||
const SEPARATOR = ' - ';
|
||||
export const SlackActionFieldsComponents: React.FC<ActionConnectorFieldsProps> = ({
|
||||
readOnly,
|
||||
isEdit,
|
||||
}) => {
|
||||
const { docLinks } = useKibana().services;
|
||||
|
||||
const form = useFormContext();
|
||||
const { setFieldValue } = form;
|
||||
const [formData] = useFormData({ form });
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [channelsToValidate, setChannelsToValidate] = useState<string>('');
|
||||
const {
|
||||
channels: validChannels,
|
||||
isLoading,
|
||||
resetChannelsToValidate,
|
||||
} = useValidChannels({
|
||||
authToken,
|
||||
channelId: channelsToValidate,
|
||||
});
|
||||
|
||||
const onCreateOption = useCallback((searchValue: string, options: EuiComboBoxOptionOption[]) => {
|
||||
setChannelsToValidate(searchValue);
|
||||
}, []);
|
||||
const onChange = useCallback(
|
||||
(options: Array<EuiComboBoxOptionOption<AllowedChannels>>) => {
|
||||
const tempChannelIds: AllowedChannels[] = options.map(
|
||||
(opt: EuiComboBoxOptionOption<AllowedChannels>) => {
|
||||
return opt.value!;
|
||||
}
|
||||
);
|
||||
setChannelsToValidate('');
|
||||
resetChannelsToValidate(tempChannelIds);
|
||||
},
|
||||
[resetChannelsToValidate]
|
||||
);
|
||||
|
||||
const configFormSchemaAfterSecrets = useMemo(() => {
|
||||
const validChannelsFormatted = validChannels.map((channel) => ({
|
||||
label: `${channel.id}${SEPARATOR}${channel.name}`,
|
||||
value: channel,
|
||||
}));
|
||||
return getConfigFormSchemaAfterSecrets({
|
||||
options: validChannelsFormatted,
|
||||
isLoading,
|
||||
isDisabled: (authToken || '').length === 0,
|
||||
onChange,
|
||||
onCreateOption,
|
||||
selectedOptions: validChannelsFormatted,
|
||||
});
|
||||
}, [validChannels, isLoading, authToken, onChange, onCreateOption]);
|
||||
|
||||
const debounceSetToken = debounce(setAuthToken, INPUT_TIMEOUT);
|
||||
useEffect(() => {
|
||||
if (formData.secrets && formData.secrets.token !== authToken) {
|
||||
debounceSetToken(formData.secrets.token);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData.secrets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(authToken) && validChannels.length > 0) {
|
||||
setFieldValue('config.allowedChannels', []);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const configAllowedChannels = formData?.config?.allowedChannels || [];
|
||||
if (!isEqual(configAllowedChannels, validChannels)) {
|
||||
setFieldValue('config.allowedChannels', validChannels);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [validChannels]);
|
||||
|
||||
const isInitialyzed = useRef(false);
|
||||
useEffect(() => {
|
||||
const configAllowedChannels = formData?.config?.allowedChannels || [];
|
||||
if (
|
||||
!isInitialyzed.current &&
|
||||
configAllowedChannels.length > 0 &&
|
||||
!isEqual(configAllowedChannels, validChannels)
|
||||
) {
|
||||
isInitialyzed.current = true;
|
||||
resetChannelsToValidate(formData.config.allowedChannels);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData.config]);
|
||||
|
||||
return (
|
||||
<SimpleConnectorForm
|
||||
isEdit={isEdit}
|
||||
readOnly={readOnly}
|
||||
configFormSchema={NO_SCHEMA}
|
||||
secretsFormSchema={getSecretsFormSchema(docLinks)}
|
||||
configFormSchemaAfterSecrets={configFormSchemaAfterSecrets}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import SlackParamsFields from './slack_params';
|
||||
import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
interface Result {
|
||||
isLoading: boolean;
|
||||
|
@ -19,23 +21,20 @@ interface Result {
|
|||
|
||||
const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
const mockUseSubAction = jest.fn<Result, [UseSubActionParams<unknown>]>(
|
||||
jest.fn<Result, [UseSubActionParams<unknown>]>(() => ({
|
||||
isLoading: false,
|
||||
response: {
|
||||
channels: [
|
||||
{
|
||||
id: 'id',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
],
|
||||
const mockUseValidChanelId = jest.fn().mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
response: {
|
||||
channel: {
|
||||
id: 'id',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
error: null,
|
||||
}))
|
||||
);
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
const mockUseSubAction = jest.fn<Result, [UseSubActionParams<unknown>]>(mockUseValidChanelId);
|
||||
|
||||
const mockToasts = { danger: jest.fn(), warning: jest.fn() };
|
||||
jest.mock(triggersActionsPath, () => {
|
||||
|
@ -51,6 +50,23 @@ jest.mock(triggersActionsPath, () => {
|
|||
});
|
||||
|
||||
describe('SlackParamsFields renders', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSubAction.mockClear();
|
||||
mockUseValidChanelId.mockClear();
|
||||
mockUseValidChanelId.mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
response: {
|
||||
channel: {
|
||||
id: 'id',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
});
|
||||
test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message', () => {
|
||||
const editAction = jest.fn();
|
||||
const { rerender } = render(
|
||||
|
@ -89,7 +105,7 @@ describe('SlackParamsFields renders', () => {
|
|||
);
|
||||
expect(editAction).toHaveBeenCalledWith(
|
||||
'subActionParams',
|
||||
{ channels: ['general'], text: 'some different default message' },
|
||||
{ channels: ['general'], channelIds: [], text: 'some different default message' },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
@ -155,17 +171,24 @@ describe('SlackParamsFields renders', () => {
|
|||
expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text');
|
||||
});
|
||||
|
||||
test('all params fields is rendered for getChannels call', async () => {
|
||||
test('Show the Channel label when using the old attribute "channels" in subActionParams', async () => {
|
||||
const mockEditFunc = jest.fn();
|
||||
const WrappedComponent = () => {
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<SlackParamsFields
|
||||
actionParams={{
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channels: [], text: 'some text' },
|
||||
subActionParams: { channels: ['old channel name'], text: 'some text' },
|
||||
}}
|
||||
actionConnector={
|
||||
{
|
||||
id: 'connector-id',
|
||||
config: {},
|
||||
} as ActionConnector
|
||||
}
|
||||
errors={{ message: [] }}
|
||||
editAction={() => {}}
|
||||
editAction={mockEditFunc}
|
||||
index={0}
|
||||
defaultMessage="default message"
|
||||
messageVariables={[]}
|
||||
|
@ -175,14 +198,188 @@ describe('SlackParamsFields renders', () => {
|
|||
};
|
||||
const { getByTestId } = render(<WrappedComponent />);
|
||||
|
||||
getByTestId('slackChannelsComboBox').click();
|
||||
getByTestId('comboBoxSearchInput').focus();
|
||||
expect(screen.findByText('Channel')).toBeTruthy();
|
||||
expect(screen.getByTestId('slackApiChannelId')).toBeInTheDocument();
|
||||
expect(getByTestId('slackApiChannelId')).toHaveValue('old channel name');
|
||||
});
|
||||
|
||||
const options = getByTestId(
|
||||
'comboBoxOptionsList slackChannelsComboBox-optionsList'
|
||||
).querySelectorAll('.euiComboBoxOption__content');
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0].textContent).toBe('general');
|
||||
test('Show the Channel ID label when using the new attribute "channelIds" in subActionParams', async () => {
|
||||
const mockEditFunc = jest.fn();
|
||||
const WrappedComponent: React.FunctionComponent = () => {
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<SlackParamsFields
|
||||
actionParams={{
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channelIds: ['channel-id-xxx'], text: 'some text' },
|
||||
}}
|
||||
actionConnector={
|
||||
{
|
||||
id: 'connector-id',
|
||||
config: {},
|
||||
} as ActionConnector
|
||||
}
|
||||
errors={{ message: [] }}
|
||||
editAction={mockEditFunc}
|
||||
index={0}
|
||||
defaultMessage="default message"
|
||||
messageVariables={[]}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
const { getByTestId } = render(<WrappedComponent />);
|
||||
|
||||
expect(screen.findByText('Channel ID')).toBeTruthy();
|
||||
expect(screen.getByTestId('slackApiChannelId')).toBeInTheDocument();
|
||||
expect(getByTestId('slackApiChannelId')).toHaveValue('channel-id-xxx');
|
||||
});
|
||||
|
||||
test('Channel id subActionParams should be validated', async () => {
|
||||
const mockEditFunc = jest.fn();
|
||||
mockUseValidChanelId.mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
response: {
|
||||
channel: {
|
||||
id: 'new-channel-id',
|
||||
name: 'new channel id',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
const WrappedComponent = () => {
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<SlackParamsFields
|
||||
actionParams={{
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channelIds: ['new-channel-id'], text: 'some text' },
|
||||
}}
|
||||
actionConnector={
|
||||
{
|
||||
id: 'connector-id',
|
||||
config: {},
|
||||
} as ActionConnector
|
||||
}
|
||||
errors={{ message: [] }}
|
||||
editAction={mockEditFunc}
|
||||
index={0}
|
||||
defaultMessage="default message"
|
||||
messageVariables={[]}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
const { getByTestId } = render(<WrappedComponent />);
|
||||
|
||||
act(() => {
|
||||
getByTestId('slackApiChannelId').click();
|
||||
userEvent.clear(getByTestId('slackApiChannelId'));
|
||||
fireEvent.change(getByTestId('slackApiChannelId'), {
|
||||
target: { value: 'new-channel-id' },
|
||||
});
|
||||
userEvent.tab();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEditFunc).toBeCalledWith(
|
||||
'subActionParams',
|
||||
{ channelIds: ['new-channel-id'], channels: undefined, text: 'some text' },
|
||||
0
|
||||
);
|
||||
expect(mockUseSubAction).toBeCalledWith({
|
||||
connectorId: 'connector-id',
|
||||
disabled: false,
|
||||
subAction: 'validChannelId',
|
||||
subActionParams: {
|
||||
channelId: 'new-channel-id',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Channel id work with combobox when allowedChannels pass in the config attributes', async () => {
|
||||
const mockEditFunc = jest.fn();
|
||||
const WrappedComponent = () => {
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<SlackParamsFields
|
||||
actionParams={{
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channelIds: ['channel-id-1'], text: 'some text' },
|
||||
}}
|
||||
actionConnector={
|
||||
{
|
||||
id: 'connector-id',
|
||||
config: {
|
||||
allowedChannels: [
|
||||
{
|
||||
id: 'channel-id-1',
|
||||
name: 'channel 1',
|
||||
},
|
||||
{
|
||||
id: 'channel-id-2',
|
||||
name: 'channel 2',
|
||||
},
|
||||
{
|
||||
id: 'channel-id-3',
|
||||
name: 'channel 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as ActionConnector
|
||||
}
|
||||
errors={{ message: [] }}
|
||||
editAction={mockEditFunc}
|
||||
index={0}
|
||||
defaultMessage="default message"
|
||||
messageVariables={[]}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
const { getByTestId } = render(<WrappedComponent />);
|
||||
|
||||
expect(screen.findByText('Channel ID')).toBeTruthy();
|
||||
expect(getByTestId('slackChannelsComboBox')).toBeInTheDocument();
|
||||
expect(getByTestId('slackChannelsComboBox').textContent).toBe('channel-id-1 - channel 1');
|
||||
|
||||
act(() => {
|
||||
const combobox = getByTestId('slackChannelsComboBox');
|
||||
const inputCombobox = within(combobox).getByTestId('comboBoxSearchInput');
|
||||
inputCombobox.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// const popOverElement = within(baseElement).getByTestId('slackChannelsComboBox-optionsList');
|
||||
expect(screen.getByTestId('channel-id-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('channel-id-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('channel-id-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId('channel-id-3').click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getByTestId('slackChannelsComboBox')).getByText('channel-id-3 - channel 3')
|
||||
).toBeInTheDocument();
|
||||
expect(mockEditFunc).toBeCalledWith(
|
||||
'subActionParams',
|
||||
{ channelIds: ['channel-id-3'], channels: undefined, text: 'some text' },
|
||||
0
|
||||
);
|
||||
expect(mockUseSubAction).toBeCalledWith({
|
||||
connectorId: 'connector-id',
|
||||
disabled: false,
|
||||
subAction: 'validChannelId',
|
||||
subActionParams: { channelId: '' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('show error message when no channel is selected', async () => {
|
||||
|
|
|
@ -9,9 +9,21 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiSpacer, EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiFormRow,
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiFieldText,
|
||||
} from '@elastic/eui';
|
||||
import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types';
|
||||
import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import type {
|
||||
PostMessageParams,
|
||||
SlackApiConfig,
|
||||
ValidChannelIdSubActionParams,
|
||||
ValidChannelResponse,
|
||||
} from '../../../common/slack_api/types';
|
||||
|
||||
const SlackParamsFields: React.FunctionComponent<ActionParamsProps<PostMessageParams>> = ({
|
||||
actionConnector,
|
||||
|
@ -23,17 +35,112 @@ const SlackParamsFields: React.FunctionComponent<ActionParamsProps<PostMessagePa
|
|||
defaultMessage,
|
||||
useDefaultMessage,
|
||||
}) => {
|
||||
const [connectorId, setConnectorId] = useState<string>();
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
const { channels = [], text } = subActionParams ?? {};
|
||||
const { channels = [], text, channelIds = [] } = subActionParams ?? {};
|
||||
const [tempChannelId, setTempChannelId] = useState(
|
||||
channels.length > 0
|
||||
? channels[0]
|
||||
: channelIds.length > 0 && channelIds[0].length > 0
|
||||
? channelIds[0]
|
||||
: ''
|
||||
);
|
||||
const [validChannelId, setValidChannelId] = useState('');
|
||||
const { toasts } = useKibana().notifications;
|
||||
const allowedChannelsConfig =
|
||||
(actionConnector as UserConfiguredActionConnector<SlackApiConfig, unknown>)?.config
|
||||
?.allowedChannels ?? [];
|
||||
const [selectedChannels, setSelectedChannels] = useState<EuiComboBoxOptionOption[]>(
|
||||
(channelIds ?? []).map((c) => {
|
||||
const allowedChannelSelected = allowedChannelsConfig?.find((ac) => ac.id === c);
|
||||
return {
|
||||
value: c,
|
||||
label: allowedChannelSelected
|
||||
? `${allowedChannelSelected.id} - ${allowedChannelSelected.name}`
|
||||
: c,
|
||||
};
|
||||
})
|
||||
);
|
||||
const [channelValidError, setChannelValidError] = useState<string[]>([]);
|
||||
const {
|
||||
response: { channel: channelValidInfo } = {},
|
||||
isLoading: isValidatingChannel,
|
||||
error: channelValidErrorResp,
|
||||
} = useSubAction<ValidChannelIdSubActionParams, ValidChannelResponse>({
|
||||
connectorId: actionConnector?.id,
|
||||
subAction: 'validChannelId',
|
||||
subActionParams: {
|
||||
channelId: validChannelId,
|
||||
},
|
||||
disabled: validChannelId.length === 0 && allowedChannelsConfig.length === 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (useDefaultMessage || !text) {
|
||||
editAction('subActionParams', { channels, text: defaultMessage }, index);
|
||||
editAction('subActionParams', { channels, channelIds, text: defaultMessage }, index);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultMessage, useDefaultMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isValidatingChannel &&
|
||||
!channelValidErrorResp &&
|
||||
channelValidInfo &&
|
||||
validChannelId === channelValidInfo.id
|
||||
) {
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{ channels: undefined, channelIds: [channelValidInfo.id], text },
|
||||
index
|
||||
);
|
||||
setValidChannelId('');
|
||||
setChannelValidError([]);
|
||||
}
|
||||
}, [
|
||||
channelValidInfo,
|
||||
validChannelId,
|
||||
channelValidErrorResp,
|
||||
isValidatingChannel,
|
||||
editAction,
|
||||
text,
|
||||
index,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channelValidErrorResp && validChannelId.length > 0) {
|
||||
editAction('subActionParams', { channels: undefined, channelIds: [], text }, index);
|
||||
const errorMessage = i18n.translate(
|
||||
'xpack.stackConnectors.slack.params.componentError.validChannelsRequestFailed',
|
||||
{
|
||||
defaultMessage: '{validChannelId} is not a valid Slack channel',
|
||||
values: {
|
||||
validChannelId,
|
||||
},
|
||||
}
|
||||
);
|
||||
setChannelValidError([errorMessage]);
|
||||
setValidChannelId('');
|
||||
toasts.danger({
|
||||
title: errorMessage,
|
||||
body: channelValidErrorResp.message,
|
||||
});
|
||||
}
|
||||
}, [toasts, channelValidErrorResp, validChannelId, editAction, text, index]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset channel id input when we changes connector
|
||||
if (connectorId && connectorId !== actionConnector?.id) {
|
||||
editAction('subActionParams', { channels: undefined, channelIds: [], text }, index);
|
||||
setTempChannelId('');
|
||||
setValidChannelId('');
|
||||
setChannelValidError([]);
|
||||
setSelectedChannels([]);
|
||||
}
|
||||
setConnectorId(actionConnector?.id ?? '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionConnector?.id]);
|
||||
|
||||
if (!subAction) {
|
||||
editAction('subAction', 'postMessage', index);
|
||||
}
|
||||
|
@ -42,82 +149,130 @@ const SlackParamsFields: React.FunctionComponent<ActionParamsProps<PostMessagePa
|
|||
'subActionParams',
|
||||
{
|
||||
channels,
|
||||
channelIds,
|
||||
text,
|
||||
},
|
||||
index
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
response: { channels: channelsInfo } = {},
|
||||
isLoading: isLoadingChannels,
|
||||
error: channelsError,
|
||||
} = useSubAction<void, GetChannelsResponse>({
|
||||
connectorId: actionConnector?.id,
|
||||
subAction: 'getChannels',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channelsError) {
|
||||
toasts.danger({
|
||||
title: i18n.translate(
|
||||
'xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed',
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve Slack channels list',
|
||||
}
|
||||
),
|
||||
body: channelsError.message,
|
||||
});
|
||||
const typeChannelInput = useMemo(() => {
|
||||
if (channels.length > 0 && channelIds.length === 0) {
|
||||
return 'channel-name';
|
||||
} else if (
|
||||
(
|
||||
(actionConnector as UserConfiguredActionConnector<SlackApiConfig, unknown>)?.config
|
||||
?.allowedChannels ?? []
|
||||
).length > 0
|
||||
) {
|
||||
return 'channel-allowed-ids';
|
||||
}
|
||||
}, [toasts, channelsError]);
|
||||
return 'channel-id';
|
||||
}, [actionConnector, channelIds.length, channels.length]);
|
||||
|
||||
const [selectedChannels, setSelectedChannels] = useState<EuiComboBoxOptionOption[]>(
|
||||
(channels ?? []).map((c) => ({ label: c }))
|
||||
);
|
||||
const slackChannelsOptions = useMemo(() => {
|
||||
return (
|
||||
(actionConnector as UserConfiguredActionConnector<SlackApiConfig, unknown>)?.config
|
||||
?.allowedChannels ?? []
|
||||
).map((ac) => ({
|
||||
label: `${ac.id} - ${ac.name}`,
|
||||
value: ac.id,
|
||||
'data-test-subj': ac.id,
|
||||
}));
|
||||
}, [actionConnector]);
|
||||
|
||||
const slackChannels = useMemo(
|
||||
() =>
|
||||
channelsInfo
|
||||
?.filter((slackChannel) => slackChannel.is_channel)
|
||||
.map((slackChannel) => ({ label: slackChannel.name })) ?? [],
|
||||
[channelsInfo]
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
const onChangeComboBox = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]) => {
|
||||
const newSelectedChannels = newOptions.map((option) => option.label);
|
||||
|
||||
const newSelectedChannels = newOptions.map<string>((option) => option.value!.toString());
|
||||
setSelectedChannels(newOptions);
|
||||
editAction('subActionParams', { channels: newSelectedChannels, text }, index);
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{ channels: undefined, channelIds: newSelectedChannels, text },
|
||||
index
|
||||
);
|
||||
},
|
||||
[editAction, index, text]
|
||||
);
|
||||
const onBlurChannelIds = useCallback(() => {
|
||||
if (tempChannelId === '') {
|
||||
editAction('subActionParams', { channels: undefined, channelIds: [], text }, index);
|
||||
}
|
||||
setValidChannelId(tempChannelId.trim());
|
||||
}, [editAction, index, tempChannelId, text]);
|
||||
|
||||
const onChangeTextField = useCallback(
|
||||
(evt) => {
|
||||
editAction('subActionParams', { channels: undefined, channelIds: [], text }, index);
|
||||
setTempChannelId(evt.target.value);
|
||||
},
|
||||
[editAction, index, text]
|
||||
);
|
||||
|
||||
const channelInput = useMemo(() => {
|
||||
if (typeChannelInput === 'channel-name' || typeChannelInput === 'channel-id') {
|
||||
return (
|
||||
<EuiFieldText
|
||||
data-test-subj="slackApiChannelId"
|
||||
name="slackApiChannelId"
|
||||
value={tempChannelId}
|
||||
isLoading={isValidatingChannel}
|
||||
onChange={onChangeTextField}
|
||||
onBlur={onBlurChannelIds}
|
||||
isInvalid={channelValidError.length > 0}
|
||||
fullWidth={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EuiComboBox
|
||||
noSuggestions={false}
|
||||
data-test-subj="slackChannelsComboBox"
|
||||
options={slackChannelsOptions}
|
||||
selectedOptions={selectedChannels}
|
||||
onChange={onChangeComboBox}
|
||||
singleSelection={true}
|
||||
fullWidth={true}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
channelValidError.length,
|
||||
isValidatingChannel,
|
||||
onBlurChannelIds,
|
||||
onChangeComboBox,
|
||||
onChangeTextField,
|
||||
selectedChannels,
|
||||
slackChannelsOptions,
|
||||
tempChannelId,
|
||||
typeChannelInput,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.stackConnectors.slack.params.channelsComboBoxLabel', {
|
||||
defaultMessage: 'Channels',
|
||||
})}
|
||||
label={
|
||||
typeChannelInput === 'channel-name'
|
||||
? i18n.translate('xpack.stackConnectors.slack.params.channelsComboBoxLabel', {
|
||||
defaultMessage: 'Channel',
|
||||
})
|
||||
: i18n.translate('xpack.stackConnectors.slack.params.channelIdComboBoxLabel', {
|
||||
defaultMessage: 'Channel ID',
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
error={errors.channels}
|
||||
isInvalid={errors.channels?.length > 0 && channels.length === 0}
|
||||
error={channelValidError.length > 0 ? channelValidError : errors.channels}
|
||||
isInvalid={errors.channels?.length > 0 || channelValidError.length > 0}
|
||||
helpText={
|
||||
channelIds.length > 0 && channelValidInfo
|
||||
? `${channelValidInfo.id} - ${channelValidInfo.name}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<EuiComboBox
|
||||
noSuggestions={false}
|
||||
data-test-subj="slackChannelsComboBox"
|
||||
isLoading={isLoadingChannels}
|
||||
options={slackChannels}
|
||||
selectedOptions={selectedChannels}
|
||||
onChange={onChange}
|
||||
singleSelection={true}
|
||||
/>
|
||||
{channelInput}
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={(key: string, value: any) =>
|
||||
editAction('subActionParams', { channels, text: value }, index)
|
||||
editAction('subActionParams', { channels, channelIds, text: value }, index)
|
||||
}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty="webApi"
|
||||
|
|
|
@ -16,7 +16,7 @@ export const MESSAGE_REQUIRED = i18n.translate(
|
|||
export const CHANNEL_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.slack_api.error.requiredSlackChannel',
|
||||
{
|
||||
defaultMessage: 'Channel is required.',
|
||||
defaultMessage: 'Channel ID is required.',
|
||||
}
|
||||
);
|
||||
export const TOKEN_LABEL = i18n.translate(
|
||||
|
@ -43,7 +43,7 @@ export const ACTION_TYPE_TITLE = i18n.translate(
|
|||
export const ALLOWED_CHANNELS = i18n.translate(
|
||||
'xpack.stackConnectors.components.slack_api.allowedChannelsLabel',
|
||||
{
|
||||
defaultMessage: 'Channels',
|
||||
defaultMessage: 'Channel IDs',
|
||||
}
|
||||
);
|
||||
export const SUCCESS_FETCH_CHANNELS = i18n.translate(
|
||||
|
@ -53,9 +53,19 @@ export const SUCCESS_FETCH_CHANNELS = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const ERROR_FETCH_CHANNELS = i18n.translate(
|
||||
'xpack.stackConnectors.components.slack_api.errorFetchChannelsText',
|
||||
export const ERROR_VALID_CHANNELS = i18n.translate(
|
||||
'xpack.stackConnectors.components.slack_api.errorValidChannelsText',
|
||||
{
|
||||
defaultMessage: 'Cannot fetch channels, please check the validity of your token',
|
||||
defaultMessage:
|
||||
'Cannot valid channels, please check the validity of your token or your channel',
|
||||
}
|
||||
);
|
||||
|
||||
export const ERROR_INVALID_CHANNELS = (invalidChannels: string[]) =>
|
||||
i18n.translate('xpack.stackConnectors.components.slack_api.errorInvalidChannelsText', {
|
||||
defaultMessage:
|
||||
'Cannot validate channel ID "{channels}", please check the validity of your token and/or the channel ID',
|
||||
values: {
|
||||
channels: invalidChannels.join(', '),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { HttpSetup } from '@kbn/core/public';
|
||||
import { ChannelsResponse, GetChannelsResponse } from '../../../common/slack_api/types';
|
||||
import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface UseFetchChannelsProps {
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
const fetchChannels = async (
|
||||
http: HttpSetup,
|
||||
newAuthToken: string
|
||||
): Promise<GetChannelsResponse> => {
|
||||
return http.post<GetChannelsResponse>(
|
||||
`${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
authToken: newAuthToken,
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export function useFetchChannels(props: UseFetchChannelsProps) {
|
||||
const { authToken } = props;
|
||||
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const queryFn = () => {
|
||||
return fetchChannels(http, authToken);
|
||||
};
|
||||
|
||||
const onErrorFn = () => {
|
||||
toasts.addDanger(i18n.ERROR_FETCH_CHANNELS);
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['fetchChannels', authToken],
|
||||
queryFn,
|
||||
onError: onErrorFn,
|
||||
enabled: authToken.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const channels = useMemo(() => {
|
||||
return (data?.channels ?? []).map((channel: ChannelsResponse) => ({
|
||||
label: channel.name,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
channels,
|
||||
isLoading: isLoading || isFetching,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { HttpSetup } from '@kbn/core/public';
|
||||
import { ValidChannelRouteResponse } from '../../../common/slack_api/types';
|
||||
import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../../common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface UseValidChannelsProps {
|
||||
authToken: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
const validChannelIds = async (
|
||||
http: HttpSetup,
|
||||
newAuthToken: string,
|
||||
channelId: string
|
||||
): Promise<ValidChannelRouteResponse> => {
|
||||
return http.post<ValidChannelRouteResponse>(
|
||||
`${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels/_valid`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
authToken: newAuthToken,
|
||||
channelIds: [channelId],
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export function useValidChannels(props: UseValidChannelsProps) {
|
||||
const { authToken, channelId } = props;
|
||||
const [channels, setChannels] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const channelIdToValidate = channels.some((c: { id: string }) => c.id === channelId)
|
||||
? ''
|
||||
: channelId;
|
||||
const queryFn = () => {
|
||||
return validChannelIds(http, authToken, channelIdToValidate);
|
||||
};
|
||||
|
||||
const onErrorFn = () => {
|
||||
toasts.addDanger(i18n.ERROR_VALID_CHANNELS);
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['validChannels', authToken, channelIdToValidate],
|
||||
queryFn,
|
||||
onError: onErrorFn,
|
||||
enabled: (authToken || '').length > 0 && (channelIdToValidate || '').length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if ((data?.invalidChannels ?? []).length > 0) {
|
||||
toasts.addDanger(i18n.ERROR_INVALID_CHANNELS(data?.invalidChannels ?? []));
|
||||
}
|
||||
if ((data?.validChannels ?? []).length > 0) {
|
||||
setChannels((prevChannels) => {
|
||||
return prevChannels.concat(data?.validChannels ?? []);
|
||||
});
|
||||
}
|
||||
}, [data, toasts]);
|
||||
|
||||
const resetChannelsToValidate = useCallback(
|
||||
(channelsToReset: Array<{ id: string; name: string }>) => {
|
||||
if (channelsToReset.length === 0) {
|
||||
setChannels([]);
|
||||
} else {
|
||||
setChannels((prevChannels) => {
|
||||
if (prevChannels.length === 0) return channelsToReset;
|
||||
return prevChannels.filter((c) => channelsToReset.some((cTr) => cTr.id === c.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
channels,
|
||||
resetChannelsToValidate,
|
||||
isLoading: isLoading && isFetching,
|
||||
};
|
||||
}
|
|
@ -18,25 +18,16 @@ const createMock = (): jest.Mocked<SlackApiService> => {
|
|||
type: 'message',
|
||||
},
|
||||
})),
|
||||
getChannels: jest.fn().mockImplementation(() => [
|
||||
validChannelId: jest.fn().mockImplementation(() => [
|
||||
{
|
||||
ok: true,
|
||||
channels: [
|
||||
{
|
||||
id: 'channel_id_1',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
{
|
||||
id: 'channel_id_2',
|
||||
name: 'privat',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
id: 'channel_id_1',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
@ -55,35 +46,28 @@ describe('api', () => {
|
|||
externalService = slackServiceMock.create();
|
||||
});
|
||||
|
||||
test('getChannels', async () => {
|
||||
const res = await api.getChannels({
|
||||
test('validChannelId', async () => {
|
||||
const res = await api.validChannelId({
|
||||
externalService,
|
||||
params: { channelId: 'channel_id_1' },
|
||||
});
|
||||
|
||||
expect(res).toEqual([
|
||||
{
|
||||
channels: [
|
||||
{
|
||||
id: 'channel_id_1',
|
||||
is_archived: false,
|
||||
is_channel: true,
|
||||
is_private: true,
|
||||
name: 'general',
|
||||
},
|
||||
{
|
||||
id: 'channel_id_2',
|
||||
is_archived: false,
|
||||
is_channel: true,
|
||||
is_private: false,
|
||||
name: 'privat',
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
id: 'channel_id_1',
|
||||
is_archived: false,
|
||||
is_channel: true,
|
||||
is_private: true,
|
||||
name: 'general',
|
||||
},
|
||||
|
||||
ok: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('postMessage', async () => {
|
||||
test('postMessage with channels params', async () => {
|
||||
const res = await api.postMessage({
|
||||
externalService,
|
||||
params: { channels: ['general'], text: 'a message' },
|
||||
|
@ -98,4 +82,20 @@ describe('api', () => {
|
|||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('postMessage with channelIds params', async () => {
|
||||
const res = await api.postMessage({
|
||||
externalService,
|
||||
params: { channelIds: ['general'], text: 'a message' },
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
channel: 'general',
|
||||
message: {
|
||||
text: 'a message',
|
||||
type: 'message',
|
||||
},
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,20 +5,29 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PostMessageSubActionParams, SlackApiService } from '../../../common/slack_api/types';
|
||||
import type {
|
||||
PostMessageSubActionParams,
|
||||
SlackApiService,
|
||||
ValidChannelIdSubActionParams,
|
||||
} from '../../../common/slack_api/types';
|
||||
|
||||
const getChannelsHandler = async ({ externalService }: { externalService: SlackApiService }) =>
|
||||
await externalService.getChannels();
|
||||
const validChannelIdHandler = async ({
|
||||
externalService,
|
||||
params: { channelId },
|
||||
}: {
|
||||
externalService: SlackApiService;
|
||||
params: ValidChannelIdSubActionParams;
|
||||
}) => await externalService.validChannelId(channelId ?? '');
|
||||
|
||||
const postMessageHandler = async ({
|
||||
externalService,
|
||||
params: { channels, text },
|
||||
params: { channelIds, channels, text },
|
||||
}: {
|
||||
externalService: SlackApiService;
|
||||
params: PostMessageSubActionParams;
|
||||
}) => await externalService.postMessage({ channels, text });
|
||||
}) => await externalService.postMessage({ channelIds, channels, text });
|
||||
|
||||
export const api = {
|
||||
getChannels: getChannelsHandler,
|
||||
validChannelId: validChannelIdHandler,
|
||||
postMessage: postMessageHandler,
|
||||
};
|
||||
|
|
|
@ -79,7 +79,7 @@ describe('validate params', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should validate and pass when params are valid for post message', () => {
|
||||
test('should validate and pass when channels is used as a valid params for post message', () => {
|
||||
expect(
|
||||
validateParams(
|
||||
connectorType,
|
||||
|
@ -92,11 +92,32 @@ describe('validate params', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should validate and pass when params are valid for get channels', () => {
|
||||
test('should validate and pass when channelIds is used as a valid params for post message', () => {
|
||||
expect(
|
||||
validateParams(connectorType, { subAction: 'getChannels' }, { configurationUtilities })
|
||||
validateParams(
|
||||
connectorType,
|
||||
{
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channelIds: ['LKJHGF345'], text: 'a text' },
|
||||
},
|
||||
{ configurationUtilities }
|
||||
)
|
||||
).toEqual({
|
||||
subAction: 'getChannels',
|
||||
subAction: 'postMessage',
|
||||
subActionParams: { channelIds: ['LKJHGF345'], text: 'a text' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate and pass when params are valid for validChannelIds', () => {
|
||||
expect(
|
||||
validateParams(
|
||||
connectorType,
|
||||
{ subAction: 'validChannelId', subActionParams: { channelId: 'KJHGFD867' } },
|
||||
{ configurationUtilities }
|
||||
)
|
||||
).toEqual({
|
||||
subAction: 'validChannelId',
|
||||
subActionParams: { channelId: 'KJHGFD867' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -179,7 +200,7 @@ describe('execute', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should fail if subAction is not postMessage/getChannels', async () => {
|
||||
test('should fail if subAction is not postMessage/validChannelId', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: {
|
||||
ok: true,
|
||||
|
@ -195,7 +216,8 @@ describe('execute', () => {
|
|||
config: {},
|
||||
secrets: { token: 'some token' },
|
||||
params: {
|
||||
subAction: 'getMessage' as 'getChannels',
|
||||
subAction: 'getMessage' as 'validChannelId',
|
||||
subActionParams: {},
|
||||
},
|
||||
configurationUtilities,
|
||||
logger: mockedLogger,
|
||||
|
@ -264,19 +286,17 @@ describe('execute', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should execute with success for get channels', async () => {
|
||||
test('should execute with success for validChannelId', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: {
|
||||
ok: true,
|
||||
channels: [
|
||||
{
|
||||
id: 'id',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
],
|
||||
channel: {
|
||||
id: 'ZXCVBNM567',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
const response = await connectorType.executor({
|
||||
|
@ -285,7 +305,10 @@ describe('execute', () => {
|
|||
config: {},
|
||||
secrets: { token: 'some token' },
|
||||
params: {
|
||||
subAction: 'getChannels',
|
||||
subAction: 'validChannelId',
|
||||
subActionParams: {
|
||||
channelId: 'ZXCVBNM567',
|
||||
},
|
||||
},
|
||||
configurationUtilities,
|
||||
logger: mockedLogger,
|
||||
|
@ -296,21 +319,19 @@ describe('execute', () => {
|
|||
configurationUtilities,
|
||||
logger: mockedLogger,
|
||||
method: 'get',
|
||||
url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000',
|
||||
url: 'conversations.info?channel=ZXCVBNM567',
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
actionId: SLACK_API_CONNECTOR_ID,
|
||||
data: {
|
||||
channels: [
|
||||
{
|
||||
id: 'id',
|
||||
is_archived: false,
|
||||
is_channel: true,
|
||||
is_private: true,
|
||||
name: 'general',
|
||||
},
|
||||
],
|
||||
channel: {
|
||||
id: 'ZXCVBNM567',
|
||||
is_archived: false,
|
||||
is_channel: true,
|
||||
is_private: true,
|
||||
name: 'general',
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
status: 'ok',
|
||||
|
|
|
@ -29,7 +29,7 @@ import { SLACK_CONNECTOR_NAME } from './translations';
|
|||
import { api } from './api';
|
||||
import { createExternalService } from './service';
|
||||
|
||||
const supportedSubActions = ['getChannels', 'postMessage'];
|
||||
const supportedSubActions = ['getAllowedChannels', 'validChannelId', 'postMessage'];
|
||||
|
||||
export const getConnectorType = (): SlackApiConnectorType => {
|
||||
return {
|
||||
|
@ -111,9 +111,10 @@ const slackApiExecutor = async ({
|
|||
configurationUtilities
|
||||
);
|
||||
|
||||
if (subAction === 'getChannels') {
|
||||
return await api.getChannels({
|
||||
if (subAction === 'validChannelId') {
|
||||
return await api.validChannelId({
|
||||
externalService,
|
||||
params: params.subActionParams,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -29,27 +29,18 @@ axios.create = jest.fn(() => axios);
|
|||
const requestMock = request as jest.Mock;
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
|
||||
const channels = [
|
||||
{
|
||||
id: 'channel_id_1',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
},
|
||||
{
|
||||
id: 'channel_id_2',
|
||||
name: 'privat',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
},
|
||||
];
|
||||
const channel = {
|
||||
id: 'channel_id_1',
|
||||
name: 'general',
|
||||
is_channel: true,
|
||||
is_archived: false,
|
||||
is_private: true,
|
||||
};
|
||||
|
||||
const getChannelsResponse = createAxiosResponse({
|
||||
const getValidChannelIdResponse = createAxiosResponse({
|
||||
data: {
|
||||
ok: true,
|
||||
channels,
|
||||
channel,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -105,30 +96,30 @@ describe('Slack API service', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getChannels', () => {
|
||||
describe('validChannelId', () => {
|
||||
test('should get slack channels', async () => {
|
||||
requestMock.mockImplementation(() => getChannelsResponse);
|
||||
const res = await service.getChannels();
|
||||
requestMock.mockImplementation(() => getValidChannelIdResponse);
|
||||
const res = await service.validChannelId('channel_id_1');
|
||||
expect(res).toEqual({
|
||||
actionId: SLACK_API_CONNECTOR_ID,
|
||||
data: {
|
||||
ok: true,
|
||||
channels,
|
||||
channel,
|
||||
},
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
test('should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => getChannelsResponse);
|
||||
requestMock.mockImplementation(() => getValidChannelIdResponse);
|
||||
|
||||
await service.getChannels();
|
||||
await service.validChannelId('channel_id_1');
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
method: 'get',
|
||||
url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000',
|
||||
url: 'conversations.info?channel=channel_id_1',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -137,7 +128,7 @@ describe('Slack API service', () => {
|
|||
throw new Error('request fail');
|
||||
});
|
||||
|
||||
expect(await service.getChannels()).toEqual({
|
||||
expect(await service.validChannelId('channel_id_1')).toEqual({
|
||||
actionId: SLACK_API_CONNECTOR_ID,
|
||||
message: 'error posting slack message',
|
||||
serviceMessage: 'request fail',
|
||||
|
@ -147,7 +138,7 @@ describe('Slack API service', () => {
|
|||
});
|
||||
|
||||
describe('postMessage', () => {
|
||||
test('should call request with correct arguments', async () => {
|
||||
test('should call request with only channels argument', async () => {
|
||||
requestMock.mockImplementation(() => postMessageResponse);
|
||||
|
||||
await service.postMessage({ channels: ['general', 'privat'], text: 'a message' });
|
||||
|
@ -163,6 +154,42 @@ describe('Slack API service', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should call request with only channelIds argument', async () => {
|
||||
requestMock.mockImplementation(() => postMessageResponse);
|
||||
|
||||
await service.postMessage({
|
||||
channels: ['general', 'privat'],
|
||||
channelIds: ['QWEERTYU987', 'POIUYT123'],
|
||||
text: 'a message',
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
method: 'post',
|
||||
url: 'chat.postMessage',
|
||||
data: { channel: 'QWEERTYU987', text: 'a message' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should call request with channels && channelIds argument', async () => {
|
||||
requestMock.mockImplementation(() => postMessageResponse);
|
||||
|
||||
await service.postMessage({ channelIds: ['QWEERTYU987', 'POIUYT123'], text: 'a message' });
|
||||
|
||||
expect(requestMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
method: 'post',
|
||||
url: 'chat.postMessage',
|
||||
data: { channel: 'QWEERTYU987', text: 'a message' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw an error if request to slack fail', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('request fail');
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios, { AxiosHeaders, AxiosResponse } from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
||||
|
@ -18,9 +18,8 @@ import type {
|
|||
PostMessageSubActionParams,
|
||||
SlackApiService,
|
||||
PostMessageResponse,
|
||||
GetChannelsResponse,
|
||||
SlackAPiResponse,
|
||||
ChannelsResponse,
|
||||
ValidChannelResponse,
|
||||
} from '../../../common/slack_api/types';
|
||||
import {
|
||||
retryResultSeconds,
|
||||
|
@ -32,9 +31,6 @@ import {
|
|||
import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants';
|
||||
import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header';
|
||||
|
||||
const RE_TRY = 5;
|
||||
const LIMIT = 1000;
|
||||
|
||||
const buildSlackExecutorErrorResponse = ({
|
||||
slackApiError,
|
||||
logger,
|
||||
|
@ -106,11 +102,20 @@ const buildSlackExecutorSuccessResponse = <T extends SlackAPiResponse>({
|
|||
};
|
||||
|
||||
export const createExternalService = (
|
||||
{ secrets }: { secrets: { token: string } },
|
||||
{
|
||||
config,
|
||||
secrets,
|
||||
}: {
|
||||
config?: { allowedChannels?: Array<{ id: string; name: string }> };
|
||||
secrets: { token: string };
|
||||
},
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities
|
||||
): SlackApiService => {
|
||||
const { token } = secrets;
|
||||
const { allowedChannels } = config || { allowedChannels: [] };
|
||||
const allowedChannelIds = allowedChannels?.map((ac) => ac.id);
|
||||
|
||||
if (!token) {
|
||||
throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`);
|
||||
}
|
||||
|
@ -123,57 +128,30 @@ export const createExternalService = (
|
|||
},
|
||||
});
|
||||
|
||||
const getChannels = async (): Promise<
|
||||
ConnectorTypeExecutorResult<GetChannelsResponse | void>
|
||||
> => {
|
||||
const validChannelId = async (
|
||||
channelId: string
|
||||
): Promise<ConnectorTypeExecutorResult<ValidChannelResponse | void>> => {
|
||||
try {
|
||||
const fetchChannels = (cursor: string = ''): Promise<AxiosResponse<GetChannelsResponse>> => {
|
||||
return request<GetChannelsResponse>({
|
||||
const validChannel = (): Promise<AxiosResponse<ValidChannelResponse>> => {
|
||||
return request<ValidChannelResponse>({
|
||||
axios: axiosInstance,
|
||||
configurationUtilities,
|
||||
logger,
|
||||
method: 'get',
|
||||
url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${
|
||||
cursor.length > 0 ? `&cursor=${cursor}` : ''
|
||||
}`,
|
||||
url: `conversations.info?channel=${channelId}`,
|
||||
});
|
||||
};
|
||||
|
||||
let numberOfFetch = 0;
|
||||
let cursor = '';
|
||||
const channels: ChannelsResponse[] = [];
|
||||
let result: AxiosResponse<GetChannelsResponse> = {
|
||||
data: { ok: false, channels },
|
||||
status: 0,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: {
|
||||
headers: new AxiosHeaders({}),
|
||||
},
|
||||
};
|
||||
|
||||
while (numberOfFetch < RE_TRY) {
|
||||
result = await fetchChannels(cursor);
|
||||
if (result.data.ok && (result.data?.channels ?? []).length > 0) {
|
||||
channels.push(...(result.data?.channels ?? []));
|
||||
}
|
||||
if (
|
||||
result.data.ok &&
|
||||
result.data.response_metadata &&
|
||||
result.data.response_metadata.next_cursor &&
|
||||
result.data.response_metadata.next_cursor.length > 0
|
||||
) {
|
||||
numberOfFetch += 1;
|
||||
cursor = result.data.response_metadata.next_cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (channelId.length === 0) {
|
||||
return buildSlackExecutorErrorResponse({
|
||||
slackApiError: new Error('The channel id is empty'),
|
||||
logger,
|
||||
});
|
||||
}
|
||||
result.data.channels = channels;
|
||||
const responseData = result.data;
|
||||
|
||||
return buildSlackExecutorSuccessResponse<GetChannelsResponse>({
|
||||
slackApiResponseData: responseData,
|
||||
const result = await validChannel();
|
||||
|
||||
return buildSlackExecutorSuccessResponse<ValidChannelResponse>({
|
||||
slackApiResponseData: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
return buildSlackExecutorErrorResponse({ slackApiError: error, logger });
|
||||
|
@ -182,15 +160,47 @@ export const createExternalService = (
|
|||
|
||||
const postMessage = async ({
|
||||
channels,
|
||||
channelIds = [],
|
||||
text,
|
||||
}: PostMessageSubActionParams): Promise<ConnectorTypeExecutorResult<unknown>> => {
|
||||
try {
|
||||
if (
|
||||
channelIds.length > 0 &&
|
||||
allowedChannelIds &&
|
||||
allowedChannelIds.length > 0 &&
|
||||
!channelIds.every((cId) => allowedChannelIds.includes(cId))
|
||||
) {
|
||||
return buildSlackExecutorErrorResponse({
|
||||
slackApiError: {
|
||||
message: `One of channel ids "${channelIds.join()}" is not included in the allowed channels list "${allowedChannelIds.join()}"`,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// For now, we only allow one channel but we wanted
|
||||
// to have a array in case we need to allow multiple channels
|
||||
// in one actions
|
||||
let channelToUse = channelIds.length > 0 ? channelIds[0] : '';
|
||||
if (channelToUse.length === 0 && channels && channels.length > 0 && channels[0].length > 0) {
|
||||
channelToUse = channels[0];
|
||||
}
|
||||
|
||||
if (channelToUse.length === 0) {
|
||||
return buildSlackExecutorErrorResponse({
|
||||
slackApiError: {
|
||||
message: `The channel is empty"`,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const result: AxiosResponse<PostMessageResponse> = await request({
|
||||
axios: axiosInstance,
|
||||
method: 'post',
|
||||
url: 'chat.postMessage',
|
||||
logger,
|
||||
data: { channel: channels[0], text },
|
||||
data: { channel: channelToUse, text },
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
|
@ -201,7 +211,7 @@ export const createExternalService = (
|
|||
};
|
||||
|
||||
return {
|
||||
getChannels,
|
||||
validChannelId,
|
||||
postMessage,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server';
|
||||
import { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server';
|
||||
import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
|
||||
import { registerConnectorTypes } from './connector_types';
|
||||
import { getWellKnownEmailServiceRoute } from './routes';
|
||||
import { validSlackApiChannelsRoute, getWellKnownEmailServiceRoute } from './routes';
|
||||
export interface ConnectorsPluginsSetup {
|
||||
actions: ActionsPluginSetupContract;
|
||||
}
|
||||
|
@ -18,13 +18,18 @@ export interface ConnectorsPluginsStart {
|
|||
}
|
||||
|
||||
export class StackConnectorsPlugin implements Plugin<void, void> {
|
||||
constructor(context: PluginInitializerContext) {}
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(context: PluginInitializerContext) {
|
||||
this.logger = context.logger.get();
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup<ConnectorsPluginsStart>, plugins: ConnectorsPluginsSetup) {
|
||||
const router = core.http.createRouter();
|
||||
const { actions } = plugins;
|
||||
|
||||
getWellKnownEmailServiceRoute(router);
|
||||
validSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger);
|
||||
|
||||
registerConnectorTypes({
|
||||
actions,
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { getWellKnownEmailServiceRoute } from './get_well_known_email_service';
|
||||
export { validSlackApiChannelsRoute } from './valid_slack_api_channels';
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import {
|
||||
IRouter,
|
||||
RequestHandlerContext,
|
||||
KibanaRequest,
|
||||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
Logger,
|
||||
} from '@kbn/core/server';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
|
||||
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
||||
import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common';
|
||||
import { SLACK_URL } from '../../common/slack_api/constants';
|
||||
import { ValidChannelResponse } from '../../common/slack_api/types';
|
||||
|
||||
const bodySchema = schema.object({
|
||||
authToken: schema.string(),
|
||||
channelIds: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 25 }),
|
||||
});
|
||||
|
||||
export const validSlackApiChannelsRoute = (
|
||||
router: IRouter,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
logger: Logger
|
||||
) => {
|
||||
router.post(
|
||||
{
|
||||
path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels/_valid`,
|
||||
validate: {
|
||||
body: bodySchema,
|
||||
},
|
||||
},
|
||||
handler
|
||||
);
|
||||
|
||||
async function handler(
|
||||
ctx: RequestHandlerContext,
|
||||
req: KibanaRequest<unknown, unknown, { authToken: string; channelIds: string[] }>,
|
||||
res: KibanaResponseFactory
|
||||
): Promise<IKibanaResponse> {
|
||||
const { authToken, channelIds } = req.body;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: SLACK_URL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'Content-type': 'application/json; charset=UTF-8',
|
||||
},
|
||||
});
|
||||
|
||||
const validChannelId = (
|
||||
channelId: string = ''
|
||||
): Promise<AxiosResponse<ValidChannelResponse>> => {
|
||||
return request<ValidChannelResponse>({
|
||||
axios: axiosInstance,
|
||||
configurationUtilities,
|
||||
logger,
|
||||
method: 'get',
|
||||
url: `conversations.info?channel=${channelId}`,
|
||||
});
|
||||
};
|
||||
|
||||
const promiseValidChannels = [];
|
||||
for (const channelId of channelIds) {
|
||||
promiseValidChannels.push(validChannelId(channelId));
|
||||
}
|
||||
const validChannels: Array<{ id: string; name: string }> = [];
|
||||
const invalidChannels: string[] = [];
|
||||
const resultValidChannels = await Promise.all(promiseValidChannels);
|
||||
|
||||
resultValidChannels.forEach((result, resultIdx) => {
|
||||
if (result.data.ok && result.data?.channel) {
|
||||
const { id, name } = result.data?.channel;
|
||||
validChannels.push({ id, name });
|
||||
} else if (result.data.error && channelIds[resultIdx]) {
|
||||
invalidChannels.push(channelIds[resultIdx]);
|
||||
}
|
||||
});
|
||||
|
||||
return res.ok({
|
||||
body: {
|
||||
validChannels,
|
||||
invalidChannels,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
|
@ -35823,7 +35823,6 @@
|
|||
"xpack.stackConnectors.components.slack_api.connectorTypeTitle": "Envoyer vers Slack",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackChannel": "Le canal est requis.",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "Le message est requis.",
|
||||
"xpack.stackConnectors.components.slack_api.errorFetchChannelsText": "Impossible de récupérer les canaux, veuillez vérifier la validité de votre token",
|
||||
"xpack.stackConnectors.components.slack_api.selectMessageText": "Envoyer des messages aux canaux Slack.",
|
||||
"xpack.stackConnectors.components.slack_api.successFetchChannelsText": "Récupérer tous les canaux",
|
||||
"xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "Token d'API",
|
||||
|
@ -35994,7 +35993,6 @@
|
|||
"xpack.stackConnectors.slack.errorPostingErrorMessage": "erreur lors de la publication du message slack",
|
||||
"xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement",
|
||||
"xpack.stackConnectors.slack.params.channelsComboBoxLabel": "Canaux",
|
||||
"xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Impossible de récupérer la liste des canaux Slack",
|
||||
"xpack.stackConnectors.slack.title": "Slack",
|
||||
"xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack",
|
||||
"xpack.stackConnectors.slackApi.title": "API Slack",
|
||||
|
|
|
@ -35822,7 +35822,6 @@
|
|||
"xpack.stackConnectors.components.slack_api.connectorTypeTitle": "Slack に送信",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackChannel": "チャンネルが必要です。",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "メッセージが必要です。",
|
||||
"xpack.stackConnectors.components.slack_api.errorFetchChannelsText": "チャンネルを取得できません。トークンの有効期限を確認してください",
|
||||
"xpack.stackConnectors.components.slack_api.selectMessageText": "メッセージをSlackチャンネルに送信します。",
|
||||
"xpack.stackConnectors.components.slack_api.successFetchChannelsText": "すべてのチャンネルを取得",
|
||||
"xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "APIトークン",
|
||||
|
@ -35993,7 +35992,6 @@
|
|||
"xpack.stackConnectors.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー",
|
||||
"xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行",
|
||||
"xpack.stackConnectors.slack.params.channelsComboBoxLabel": "チャンネル",
|
||||
"xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Slackチャンネルリストを取得できませんでした",
|
||||
"xpack.stackConnectors.slack.title": "Slack",
|
||||
"xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "Slack から予期せぬ null 応答",
|
||||
"xpack.stackConnectors.slackApi.title": "Slack API",
|
||||
|
|
|
@ -35816,7 +35816,6 @@
|
|||
"xpack.stackConnectors.components.slack_api.connectorTypeTitle": "发送到 Slack",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackChannel": "“频道”必填。",
|
||||
"xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "“消息”必填。",
|
||||
"xpack.stackConnectors.components.slack_api.errorFetchChannelsText": "无法提取频道,请检查您令牌的有效性",
|
||||
"xpack.stackConnectors.components.slack_api.selectMessageText": "向 Slack 频道发送消息。",
|
||||
"xpack.stackConnectors.components.slack_api.successFetchChannelsText": "提取所有频道",
|
||||
"xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "API 令牌",
|
||||
|
@ -35987,7 +35986,6 @@
|
|||
"xpack.stackConnectors.slack.errorPostingErrorMessage": "发布 slack 消息时出错",
|
||||
"xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试",
|
||||
"xpack.stackConnectors.slack.params.channelsComboBoxLabel": "频道",
|
||||
"xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "无法检索 Slack 频道列表",
|
||||
"xpack.stackConnectors.slack.title": "Slack",
|
||||
"xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "来自 slack 的异常空响应",
|
||||
"xpack.stackConnectors.slackApi.title": "Slack API",
|
||||
|
|
|
@ -68,7 +68,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
objectRemover.add(connector.id, 'action', 'actions');
|
||||
});
|
||||
|
||||
it('should create the web api connector', async () => {
|
||||
/* FUTURE ENGINEER
|
||||
/* With this https://github.com/elastic/kibana/pull/167150, we added an allowed list of channel IDs
|
||||
/* we can not have this test running anymore because this allowed list is required
|
||||
/* we will have to figure out how to simulate the slack API through functional/API integration testing
|
||||
*/
|
||||
it.skip('should create the web api connector', async () => {
|
||||
const connectorName = generateUniqueKey();
|
||||
await actions.slack.createNewWebAPI({
|
||||
name: connectorName,
|
||||
|
@ -165,7 +170,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
expect(toastTitle).to.eql(`Created rule "${ruleName}"`);
|
||||
});
|
||||
|
||||
it('should save webapi type slack connectors', async () => {
|
||||
/* FUTURE ENGINEER
|
||||
/* With this https://github.com/elastic/kibana/pull/167150, we added an allowed list of channel IDs
|
||||
/* we can not have this test running anymore because this allowed list is required
|
||||
/* we will have to figure out how to simulate the slack API through functional/API integration testing
|
||||
*/
|
||||
it.skip('should save webapi type slack connectors', async () => {
|
||||
await setupRule();
|
||||
await selectSlackConnectorInRuleAction({
|
||||
connectorId: webApiAction.id,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue