From de5cfc5dce590e3a019d7d83c56f6dfbd91fa67e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 5 Dec 2025 17:55:18 +0100 Subject: [PATCH 01/12] Remove validation of channels --- .../src/common/types/action_types.ts | 19 +- .../connector_types/slack_api/slack_api.tsx | 32 ++- .../slack_api/slack_connectors.tsx | 164 +++--------- .../slack_api/slack_params.tsx | 253 ++++++------------ .../connector_types/slack_api/translations.ts | 16 +- .../slack_api/use_valid_channels.tsx | 101 ------- .../shared/stack_connectors/server/plugin.ts | 11 +- .../stack_connectors/server/routes/index.ts | 1 - .../server/routes/valid_slack_api_channels.ts | 103 ------- .../components/simple_connector_form.tsx | 29 +- 10 files changed, 194 insertions(+), 535 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx delete mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts index ebf3441348c76..f62c39e3b23dc 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts @@ -86,11 +86,12 @@ export type ConnectorFormSchema< > & Partial, 'id' | 'name'>>; -export type InternalConnectorForm = ConnectorFormSchema & { - __internal__?: { - headers?: Array<{ key: string; value: string; type: string }>; +export type InternalConnectorForm = Record> = + ConnectorFormSchema & { + __internal__?: { + headers?: Array<{ key: string; value: string; type: string }>; + }; }; -}; export interface ActionParamsProps { actionParams: Partial; @@ -159,12 +160,18 @@ export interface ActionTypeModel; + deserializer?: SerializerFunc< + InternalConnectorForm, + ConnectorFormSchema + >; /** * Form hook lib serializer used in the connector form * Use this to transform the intermediate state used in the form into a connector object */ - serializer?: SerializerFunc; + serializer?: SerializerFunc< + ConnectorFormSchema, + InternalConnectorForm + >; /** * If true, hides the settings title of the connector form * @default false diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index b203f5070d655..8b3b8ce356ea6 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -30,7 +30,7 @@ import { import type { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; -const isChannelValid = (channels?: string[], channelIds?: string[]) => { +const isChannelValid = (channels?: string[], channelIds?: string[], channelNames?: []) => { if ( (channels === undefined && !channelIds?.length) || (channelIds === undefined && !channels?.length) || @@ -71,7 +71,8 @@ export const getConnectorType = (): ConnectorTypeModel< if ( !isChannelValid( actionParams.subActionParams.channels, - actionParams.subActionParams.channelIds + actionParams.subActionParams.channelIds, + actionParams.subActionParams.channelNames ) ) { errors.channels.push(CHANNEL_REQUIRED); @@ -108,4 +109,31 @@ export const getConnectorType = (): ConnectorTypeModel< } return {}; }, + connectorForm: { + serializer: (data) => { + const formAllowedChannels = data.config?.allowedChannels ?? []; + + const allowedChannels = formAllowedChannels.map((option) => ({ name: option })) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels }, + }; + }, + deserializer: (data) => { + const allowedChannels = + data.config?.allowedChannels?.map((channel) => { + if (channel.name.startsWith('#')) { + return channel.name; + } + + return `#${channel.name}`; + }) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels }, + }; + }, + }, }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 4223f7bba881d..b6d644ae089d3 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -5,26 +5,19 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React from 'react'; import type { ActionConnectorFieldsProps, ConfigFieldSchema, SecretsFieldSchema, } from '@kbn/triggers-actions-ui-plugin/public'; import { SimpleConnectorForm, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; +import { EuiLink, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { 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 '@kbn/react-query'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; 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[] => [ { @@ -42,152 +35,61 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => }, ]; -interface AllowedChannels { - id: string; - name: string; -} - -const getConfigFormSchemaAfterSecrets = ({ - options, - isLoading, - isDisabled, - onChange, - onCreateOption, - selectedOptions, -}: { - options: Array>; - isLoading: boolean; - isDisabled: boolean; - onChange: (options: Array>) => void; - onCreateOption: (searchValue: string, options: EuiComboBoxOptionOption[]) => void; - selectedOptions: Array>; -}): ConfigFieldSchema[] => [ +const getConfigFormSchema = (): ConfigFieldSchema[] => [ { id: 'allowedChannels', - isRequired: true, + isRequired: false, label: i18n.ALLOWED_CHANNELS, type: 'COMBO_BOX', + validations: [ + { + isBlocking: false, + validator: (args) => { + // console.log('args.value', args.value, typeof args.value); + // // const areAllValid = args.value.every((value) => value.startsWith('#')); + // // if (areAllValid) { + // // return; + // // } + // if (args.value.startsWith('#')) { + // return; + // } + // return { + // code: 'ERR_FIELD_FORMAT', + // formatType: 'COMBO_BOX', + // message: i18n.CHANNEL_NAME_ERROR, + // }; + }, + }, + ], euiFieldProps: { noSuggestions: true, - isDisabled, - isLoading, - options, - onChange, - onCreateOption, - selectedOptions, + autoComplete: 'off', + append: ( + + {i18n.OPTIONAL_LABEL} + + ), }, }, ]; -const NO_SCHEMA: ConfigFieldSchema[] = []; -const SEPARATOR = ' - '; export const SlackActionFieldsComponents: React.FC = ({ readOnly, isEdit, }) => { const { docLinks } = useKibana().services; - const form = useFormContext(); - const { setFieldValue } = form; - const [formData] = useFormData({ form }); - const [authToken, setAuthToken] = useState(''); - const [channelsToValidate, setChannelsToValidate] = useState(''); - const { - channels: validChannels, - isLoading, - resetChannelsToValidate, - } = useValidChannels({ - authToken, - channelId: channelsToValidate, - }); - - const onCreateOption = useCallback((searchValue: string, options: EuiComboBoxOptionOption[]) => { - setChannelsToValidate(searchValue); - }, []); - const onChange = useCallback( - (options: Array>) => { - const tempChannelIds: AllowedChannels[] = options.map( - (opt: EuiComboBoxOptionOption) => { - 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 ( ); }; -export const simpleConnectorQueryClient = new QueryClient(); - -const SlackActionFields: React.FC = (props) => ( - - - -); +const SlackActionFields = React.memo(SlackActionFieldsComponents); // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index adb9d93b41894..a485aed2cec3f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -16,19 +16,16 @@ import { EuiSpacer, EuiFormRow, EuiComboBox, - EuiFieldText, EuiButtonGroup, EuiLink, + useGeneratedHtmlId, } from '@elastic/eui'; -import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; import type { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; import type { PostBlockkitParams, PostMessageParams, SlackApiConfig, - ValidChannelIdSubActionParams, } from '@kbn/connector-schemas/slack_api'; -import type { ValidChannelResponse } from '../../../common/slack_api/types'; const SlackParamsFields: React.FunctionComponent< ActionParamsProps @@ -42,47 +39,32 @@ const SlackParamsFields: React.FunctionComponent< defaultMessage, useDefaultMessage, }) => { + const channelsLabelId = useGeneratedHtmlId(); + const [connectorId, setConnectorId] = useState(); const { subAction, subActionParams } = actionParams; - const { channels = [], text, channelIds = [] } = subActionParams ?? {}; - const [tempChannelId, setTempChannelId] = useState( - channels.length > 0 - ? channels[0] - : channelIds.length > 0 && channelIds[0].length > 0 - ? channelIds[0] - : '' - ); + const { channels = [], text, channelIds = [], channelNames = [] } = subActionParams ?? {}; + + const selectedChannel = getSelectedChannel({ channels, channelIds, channelNames }); + const [messageType, setMessageType] = useState('text'); const [textValue, setTextValue] = useState(text); - const [validChannelId, setValidChannelId] = useState(''); - const { toasts } = useKibana().services.notifications; + const allowedChannelsConfig = (actionConnector as UserConfiguredActionConnector)?.config ?.allowedChannels ?? []; - const [selectedChannels, setSelectedChannels] = useState( - (channelIds ?? []).map((c) => { - const allowedChannelSelected = allowedChannelsConfig?.find((ac) => ac.id === c); - return { - value: c, - label: allowedChannelSelected - ? `${allowedChannelSelected.id} - ${allowedChannelSelected.name}` - : c, - }; - }) + + const slackChannelsOptions = allowedChannelsConfig.map((ac) => ({ + label: formatChannel(ac.name), + value: formatChannel(ac.name), + 'data-test-subj': ac.name, + })); + + const shouldAllowAnyChannel = !Boolean(slackChannelsOptions.length); + + const [selectedChannels, setSelectedChannels] = useState[]>( + selectedChannel ? [{ value: selectedChannel, label: selectedChannel }] : [] ); - const [channelValidError, setChannelValidError] = useState([]); - const { - response: { channel: channelValidInfo } = {}, - isLoading: isValidatingChannel, - error: channelValidErrorResp, - } = useSubAction({ - connectorId: actionConnector?.id, - subAction: 'validChannelId', - subActionParams: { - channelId: validChannelId, - }, - disabled: validChannelId.length === 0 && allowedChannelsConfig.length === 0, - }); const onToggleInput = useCallback( (id: string) => { @@ -96,56 +78,14 @@ const SlackParamsFields: React.FunctionComponent< useEffect(() => { if (useDefaultMessage || !text) { - 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 }, + { channels, channelIds, channelNames, text: defaultMessage }, 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.addDanger({ - title: errorMessage, - text: channelValidErrorResp.message, - }); - } - }, [toasts, channelValidErrorResp, validChannelId, editAction, text, index]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage, useDefaultMessage]); useEffect(() => { if (subAction) { @@ -154,14 +94,16 @@ const SlackParamsFields: React.FunctionComponent< }, [subAction]); useEffect(() => { - // Reset channel id input when we changes connector + // Reset channel names input when we changes connector if (connectorId && connectorId !== actionConnector?.id) { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); - setTempChannelId(''); - setValidChannelId(''); - setChannelValidError([]); + editAction( + 'subActionParams', + { channels: undefined, channelIds: undefined, channelNames: [], text }, + index + ); setSelectedChannels([]); } + setConnectorId(actionConnector?.id ?? ''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector?.id]); @@ -169,127 +111,74 @@ const SlackParamsFields: React.FunctionComponent< if (!subAction) { editAction('subAction', 'postMessage', index); } + if (!subActionParams) { editAction( 'subActionParams', { channels, channelIds, + channelNames, text, }, index ); } - const typeChannelInput = useMemo(() => { - if (channels.length > 0 && channelIds.length === 0) { - return 'channel-name'; - } else if ( - ( - (actionConnector as UserConfiguredActionConnector)?.config - ?.allowedChannels ?? [] - ).length > 0 - ) { - return 'channel-allowed-ids'; - } - return 'channel-id'; - }, [actionConnector, channelIds.length, channels.length]); - - const slackChannelsOptions = useMemo(() => { - return ( - (actionConnector as UserConfiguredActionConnector)?.config - ?.allowedChannels ?? [] - ).map((ac) => ({ - label: `${ac.id} - ${ac.name}`, - value: ac.id, - 'data-test-subj': ac.id, - })); - }, [actionConnector]); const onChangeComboBox = useCallback( - (newOptions: EuiComboBoxOptionOption[]) => { + (newOptions: EuiComboBoxOptionOption[]) => { const newSelectedChannels = newOptions.map((option) => option.value!.toString()); setSelectedChannels(newOptions); + editAction( 'subActionParams', - { channels: undefined, channelIds: newSelectedChannels, text }, + { channels: undefined, channelIds: undefined, channelNames: newSelectedChannels, text }, index ); }, [editAction, index, text] ); - const onBlurChannelIds = useCallback(() => { - if (tempChannelId === '') { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); + + const onCreateOption = (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; } - setValidChannelId(tempChannelId.trim()); - }, [editAction, index, tempChannelId, text]); - const onChangeTextField = useCallback( - (evt: React.ChangeEvent) => { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); - setTempChannelId(evt.target.value); - }, - [editAction, index, text] - ); + const newOption = { + label: searchValue, + }; + + setSelectedChannels([newOption]); + }; const channelInput = useMemo(() => { - if (typeChannelInput === 'channel-name' || typeChannelInput === 'channel-id') { - return ( - 0} - fullWidth={true} - /> - ); - } return ( ); - }, [ - channelValidError.length, - isValidatingChannel, - onBlurChannelIds, - onChangeComboBox, - onChangeTextField, - selectedChannels, - slackChannelsOptions, - tempChannelId, - typeChannelInput, - ]); + }, [onChangeComboBox, selectedChannels, shouldAllowAnyChannel, slackChannelsOptions]); return ( <> 0 ? channelValidError : (errors.channels as string[])} - isInvalid={Number(errors.channels?.length) > 0 || channelValidError.length > 0} - helpText={ - channelIds.length > 0 && channelValidInfo - ? `${channelValidInfo.id} - ${channelValidInfo.name}` - : '' - } + error={errors.channels as string[]} + isInvalid={Number(errors.channels?.length) > 0} + id={channelsLabelId} > {channelInput} @@ -380,3 +269,33 @@ const SlackParamsFields: React.FunctionComponent< // eslint-disable-next-line import/no-default-export export { SlackParamsFields as default }; + +const getSelectedChannel = ({ + channels, + channelIds, + channelNames, +}: { + channels?: string[]; + channelIds?: string[]; + channelNames?: string[]; +}) => { + if (channelNames && channelNames.length > 0) { + return channelNames[0]; + } + + if (channelIds && channelIds.length > 0) { + return channelIds[0]; + } + + if (channels && channels.length > 0) { + return channels[0]; + } +}; + +const formatChannel = (channel: string): string => { + if (channel.startsWith('#')) { + return channel; + } + + return `#${channel}`; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts index f08864bda94b9..db3a4b4c9774b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts @@ -43,7 +43,7 @@ export const ACTION_TYPE_TITLE = i18n.translate( export const ALLOWED_CHANNELS = i18n.translate( 'xpack.stackConnectors.components.slack_api.allowedChannelsLabel', { - defaultMessage: 'Channel IDs', + defaultMessage: 'Allowed channels', } ); export const SUCCESS_FETCH_CHANNELS = i18n.translate( @@ -83,3 +83,17 @@ export const BLOCKS_REQUIRED = i18n.translate( defaultMessage: `JSON must contain field "blocks".`, } ); + +export const OPTIONAL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.slack_api.optionalLabel', + { + defaultMessage: 'Optional', + } +); + +export const CHANNEL_NAME_ERROR = i18n.translate( + 'xpack.stackConnectors.components.slack_api.channelNameError', + { + defaultMessage: 'Channel name must start with a #', + } +); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx deleted file mode 100644 index 2dc6106e1e617..0000000000000 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx +++ /dev/null @@ -1,101 +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 { useCallback, useEffect, useState } from 'react'; -import { useQuery } from '@kbn/react-query'; -import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import type { HttpSetup } from '@kbn/core/public'; -import type { 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 => { - return http.post( - `${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>([]); - 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, - }; -} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 7860eed617b72..e4e28912df306 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server'; +import type { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; @@ -15,11 +15,7 @@ import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-obje import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { registerInferenceConnectorsUsageCollector } from './usage/inference/inference_connectors_usage_collector'; import { registerConnectorTypes } from './connector_types'; -import { - validSlackApiChannelsRoute, - getWellKnownEmailServiceRoute, - getWebhookSecretHeadersKeyRoute, -} from './routes'; +import { getWellKnownEmailServiceRoute, getWebhookSecretHeadersKeyRoute } from './routes'; import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import type { ConfigSchema as StackConnectorsConfigType } from './config'; @@ -38,12 +34,10 @@ export interface ConnectorsPluginsStart { export class StackConnectorsPlugin implements Plugin { - private readonly logger: Logger; private config: StackConnectorsConfigType; readonly experimentalFeatures: ExperimentalFeatures; constructor(context: PluginInitializerContext) { - this.logger = context.logger.get(); this.config = context.config.get(); this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); } @@ -55,7 +49,6 @@ export class StackConnectorsPlugin const awsSesConfig = actions.getActionsConfigurationUtilities().getAwsSesConfig(); getWellKnownEmailServiceRoute(router, awsSesConfig); - validSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger); getWebhookSecretHeadersKeyRoute(router, core.getStartServices); registerConnectorTypes({ diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts index a4c6d4d8c5e02..df595cb4256fe 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts @@ -6,5 +6,4 @@ */ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; -export { validSlackApiChannelsRoute } from './valid_slack_api_channels'; export { getWebhookSecretHeadersKeyRoute } from './get_webhook_secret_headers_key'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts deleted file mode 100644 index 0144c5d4e07c2..0000000000000 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts +++ /dev/null @@ -1,103 +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. - */ - -import { schema } from '@kbn/config-schema'; -import type { - IRouter, - RequestHandlerContext, - KibanaRequest, - IKibanaResponse, - KibanaResponseFactory, - Logger, -} from '@kbn/core/server'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; -import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; -import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; -import { SLACK_URL } from '@kbn/connector-schemas/slack_api'; -import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; - -import type { 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`, - security: { - authz: { - enabled: false, - reason: - "This route is opted out from authorization as it relies on Slack's own authorization.", - }, - }, - validate: { - body: bodySchema, - }, - options: { - access: 'internal', - }, - }, - handler - ); - - async function handler( - ctx: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise { - const { authToken, channelIds } = req.body; - - const axiosInstance = axios.create(); - - const validChannelId = (channelId = ''): Promise> => { - return request({ - axios: axiosInstance, - configurationUtilities, - headers: { - Authorization: `Bearer ${authToken}`, - 'Content-type': 'application/json; charset=UTF-8', - }, - logger, - method: 'get', - url: `${SLACK_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, - }, - }); - } -}; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx index 80133e9b019dd..89c228021fbf3 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx @@ -13,23 +13,27 @@ import { Field, PasswordField, } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { ValidationConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { FIELD_TYPES, getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { i18n } from '@kbn/i18n'; -export interface CommonFieldSchema { +type Validations = Array>; + +export interface CommonFieldSchema { id: string; label: string; helpText?: string | ReactNode; isRequired?: boolean; type?: keyof typeof FIELD_TYPES; euiFieldProps?: Record; + validations?: Validations; } -export interface ConfigFieldSchema extends CommonFieldSchema { +export interface ConfigFieldSchema extends CommonFieldSchema { isUrlField?: boolean; requireTld?: boolean; - defaultValue?: string | string[]; + defaultValue?: T; } export interface SecretsFieldSchema extends CommonFieldSchema { @@ -41,7 +45,6 @@ interface SimpleConnectorFormProps { readOnly: boolean; configFormSchema: ConfigFieldSchema[]; secretsFormSchema: SecretsFieldSchema[]; - configFormSchemaAfterSecrets?: ConfigFieldSchema[]; } type FormRowProps = ConfigFieldSchema & SecretsFieldSchema & { readOnly: boolean }; @@ -50,20 +53,22 @@ const UseTextField = getUseField({ component: Field }); const UseComboBoxField = getUseField({ component: ComboBoxField }); const { emptyField, urlField } = fieldValidators; -const getFieldConfig = ({ +const getFieldConfig = ({ label, isRequired = true, isUrlField = false, requireTld = true, defaultValue, type, + validations = [], }: { label: string; isRequired?: boolean; isUrlField?: boolean; requireTld?: boolean; - defaultValue?: string | string[]; + defaultValue?: T; type?: keyof typeof FIELD_TYPES; + validations?: Validations; }) => ({ label, validations: [ @@ -97,6 +102,7 @@ const getFieldConfig = ({ }, ] : []), + ...validations, ], defaultValue, ...(type && FIELD_TYPES[type] @@ -124,6 +130,7 @@ const FormRow: React.FC = ({ euiFieldProps = {}, type, requireTld, + validations = [], }) => { const dataTestSub = `${id}-input`; const UseField = getComponentByType(type); @@ -141,6 +148,7 @@ const FormRow: React.FC = ({ type, isRequired, requireTld, + validations, })} helpText={helpText} componentProps={{ @@ -155,7 +163,7 @@ const FormRow: React.FC = ({ ) : ( = ({ readOnly, configFormSchema, secretsFormSchema, - configFormSchemaAfterSecrets = [], }) => { return ( <> @@ -214,12 +221,6 @@ const SimpleConnectorFormComponent: React.FC = ({ {index !== secretsFormSchema.length ? : null} ))} - {configFormSchemaAfterSecrets.map(({ id, ...restConfigSchemaAfterSecrets }, index) => ( - - - {index !== configFormSchemaAfterSecrets.length ? : null} - - ))} ); }; From 663a4db186ba6224ca5e366a56eb08f41c124574 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 5 Dec 2025 17:55:18 +0100 Subject: [PATCH 02/12] Remove validation of channels --- .../src/common/types/action_types.ts | 19 +- .../connector_types/slack_api/slack_api.tsx | 32 ++- .../slack_api/slack_connectors.tsx | 164 +++--------- .../slack_api/slack_params.tsx | 253 ++++++------------ .../connector_types/slack_api/translations.ts | 16 +- .../slack_api/use_valid_channels.tsx | 101 ------- .../shared/stack_connectors/server/plugin.ts | 11 +- .../stack_connectors/server/routes/index.ts | 1 - .../server/routes/valid_slack_api_channels.ts | 103 ------- .../components/simple_connector_form.tsx | 29 +- 10 files changed, 194 insertions(+), 535 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx delete mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts index ebf3441348c76..f62c39e3b23dc 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts @@ -86,11 +86,12 @@ export type ConnectorFormSchema< > & Partial, 'id' | 'name'>>; -export type InternalConnectorForm = ConnectorFormSchema & { - __internal__?: { - headers?: Array<{ key: string; value: string; type: string }>; +export type InternalConnectorForm = Record> = + ConnectorFormSchema & { + __internal__?: { + headers?: Array<{ key: string; value: string; type: string }>; + }; }; -}; export interface ActionParamsProps { actionParams: Partial; @@ -159,12 +160,18 @@ export interface ActionTypeModel; + deserializer?: SerializerFunc< + InternalConnectorForm, + ConnectorFormSchema + >; /** * Form hook lib serializer used in the connector form * Use this to transform the intermediate state used in the form into a connector object */ - serializer?: SerializerFunc; + serializer?: SerializerFunc< + ConnectorFormSchema, + InternalConnectorForm + >; /** * If true, hides the settings title of the connector form * @default false diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index b203f5070d655..74d42e5db3fac 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -30,7 +30,7 @@ import { import type { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; -const isChannelValid = (channels?: string[], channelIds?: string[]) => { +const isChannelValid = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { if ( (channels === undefined && !channelIds?.length) || (channelIds === undefined && !channels?.length) || @@ -71,7 +71,8 @@ export const getConnectorType = (): ConnectorTypeModel< if ( !isChannelValid( actionParams.subActionParams.channels, - actionParams.subActionParams.channelIds + actionParams.subActionParams.channelIds, + actionParams.subActionParams.channelNames ) ) { errors.channels.push(CHANNEL_REQUIRED); @@ -108,4 +109,31 @@ export const getConnectorType = (): ConnectorTypeModel< } return {}; }, + connectorForm: { + serializer: (data) => { + const formAllowedChannels = data.config?.allowedChannels ?? []; + + const allowedChannels = formAllowedChannels.map((option) => ({ name: option })) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels }, + }; + }, + deserializer: (data) => { + const allowedChannels = + data.config?.allowedChannels?.map((channel) => { + if (channel.name.startsWith('#')) { + return channel.name; + } + + return `#${channel.name}`; + }) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels }, + }; + }, + }, }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 4223f7bba881d..b6d644ae089d3 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -5,26 +5,19 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React from 'react'; import type { ActionConnectorFieldsProps, ConfigFieldSchema, SecretsFieldSchema, } from '@kbn/triggers-actions-ui-plugin/public'; import { SimpleConnectorForm, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; +import { EuiLink, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { 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 '@kbn/react-query'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; 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[] => [ { @@ -42,152 +35,61 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => }, ]; -interface AllowedChannels { - id: string; - name: string; -} - -const getConfigFormSchemaAfterSecrets = ({ - options, - isLoading, - isDisabled, - onChange, - onCreateOption, - selectedOptions, -}: { - options: Array>; - isLoading: boolean; - isDisabled: boolean; - onChange: (options: Array>) => void; - onCreateOption: (searchValue: string, options: EuiComboBoxOptionOption[]) => void; - selectedOptions: Array>; -}): ConfigFieldSchema[] => [ +const getConfigFormSchema = (): ConfigFieldSchema[] => [ { id: 'allowedChannels', - isRequired: true, + isRequired: false, label: i18n.ALLOWED_CHANNELS, type: 'COMBO_BOX', + validations: [ + { + isBlocking: false, + validator: (args) => { + // console.log('args.value', args.value, typeof args.value); + // // const areAllValid = args.value.every((value) => value.startsWith('#')); + // // if (areAllValid) { + // // return; + // // } + // if (args.value.startsWith('#')) { + // return; + // } + // return { + // code: 'ERR_FIELD_FORMAT', + // formatType: 'COMBO_BOX', + // message: i18n.CHANNEL_NAME_ERROR, + // }; + }, + }, + ], euiFieldProps: { noSuggestions: true, - isDisabled, - isLoading, - options, - onChange, - onCreateOption, - selectedOptions, + autoComplete: 'off', + append: ( + + {i18n.OPTIONAL_LABEL} + + ), }, }, ]; -const NO_SCHEMA: ConfigFieldSchema[] = []; -const SEPARATOR = ' - '; export const SlackActionFieldsComponents: React.FC = ({ readOnly, isEdit, }) => { const { docLinks } = useKibana().services; - const form = useFormContext(); - const { setFieldValue } = form; - const [formData] = useFormData({ form }); - const [authToken, setAuthToken] = useState(''); - const [channelsToValidate, setChannelsToValidate] = useState(''); - const { - channels: validChannels, - isLoading, - resetChannelsToValidate, - } = useValidChannels({ - authToken, - channelId: channelsToValidate, - }); - - const onCreateOption = useCallback((searchValue: string, options: EuiComboBoxOptionOption[]) => { - setChannelsToValidate(searchValue); - }, []); - const onChange = useCallback( - (options: Array>) => { - const tempChannelIds: AllowedChannels[] = options.map( - (opt: EuiComboBoxOptionOption) => { - 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 ( ); }; -export const simpleConnectorQueryClient = new QueryClient(); - -const SlackActionFields: React.FC = (props) => ( - - - -); +const SlackActionFields = React.memo(SlackActionFieldsComponents); // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index adb9d93b41894..a485aed2cec3f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -16,19 +16,16 @@ import { EuiSpacer, EuiFormRow, EuiComboBox, - EuiFieldText, EuiButtonGroup, EuiLink, + useGeneratedHtmlId, } from '@elastic/eui'; -import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; import type { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; import type { PostBlockkitParams, PostMessageParams, SlackApiConfig, - ValidChannelIdSubActionParams, } from '@kbn/connector-schemas/slack_api'; -import type { ValidChannelResponse } from '../../../common/slack_api/types'; const SlackParamsFields: React.FunctionComponent< ActionParamsProps @@ -42,47 +39,32 @@ const SlackParamsFields: React.FunctionComponent< defaultMessage, useDefaultMessage, }) => { + const channelsLabelId = useGeneratedHtmlId(); + const [connectorId, setConnectorId] = useState(); const { subAction, subActionParams } = actionParams; - const { channels = [], text, channelIds = [] } = subActionParams ?? {}; - const [tempChannelId, setTempChannelId] = useState( - channels.length > 0 - ? channels[0] - : channelIds.length > 0 && channelIds[0].length > 0 - ? channelIds[0] - : '' - ); + const { channels = [], text, channelIds = [], channelNames = [] } = subActionParams ?? {}; + + const selectedChannel = getSelectedChannel({ channels, channelIds, channelNames }); + const [messageType, setMessageType] = useState('text'); const [textValue, setTextValue] = useState(text); - const [validChannelId, setValidChannelId] = useState(''); - const { toasts } = useKibana().services.notifications; + const allowedChannelsConfig = (actionConnector as UserConfiguredActionConnector)?.config ?.allowedChannels ?? []; - const [selectedChannels, setSelectedChannels] = useState( - (channelIds ?? []).map((c) => { - const allowedChannelSelected = allowedChannelsConfig?.find((ac) => ac.id === c); - return { - value: c, - label: allowedChannelSelected - ? `${allowedChannelSelected.id} - ${allowedChannelSelected.name}` - : c, - }; - }) + + const slackChannelsOptions = allowedChannelsConfig.map((ac) => ({ + label: formatChannel(ac.name), + value: formatChannel(ac.name), + 'data-test-subj': ac.name, + })); + + const shouldAllowAnyChannel = !Boolean(slackChannelsOptions.length); + + const [selectedChannels, setSelectedChannels] = useState[]>( + selectedChannel ? [{ value: selectedChannel, label: selectedChannel }] : [] ); - const [channelValidError, setChannelValidError] = useState([]); - const { - response: { channel: channelValidInfo } = {}, - isLoading: isValidatingChannel, - error: channelValidErrorResp, - } = useSubAction({ - connectorId: actionConnector?.id, - subAction: 'validChannelId', - subActionParams: { - channelId: validChannelId, - }, - disabled: validChannelId.length === 0 && allowedChannelsConfig.length === 0, - }); const onToggleInput = useCallback( (id: string) => { @@ -96,56 +78,14 @@ const SlackParamsFields: React.FunctionComponent< useEffect(() => { if (useDefaultMessage || !text) { - 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 }, + { channels, channelIds, channelNames, text: defaultMessage }, 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.addDanger({ - title: errorMessage, - text: channelValidErrorResp.message, - }); - } - }, [toasts, channelValidErrorResp, validChannelId, editAction, text, index]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage, useDefaultMessage]); useEffect(() => { if (subAction) { @@ -154,14 +94,16 @@ const SlackParamsFields: React.FunctionComponent< }, [subAction]); useEffect(() => { - // Reset channel id input when we changes connector + // Reset channel names input when we changes connector if (connectorId && connectorId !== actionConnector?.id) { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); - setTempChannelId(''); - setValidChannelId(''); - setChannelValidError([]); + editAction( + 'subActionParams', + { channels: undefined, channelIds: undefined, channelNames: [], text }, + index + ); setSelectedChannels([]); } + setConnectorId(actionConnector?.id ?? ''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector?.id]); @@ -169,127 +111,74 @@ const SlackParamsFields: React.FunctionComponent< if (!subAction) { editAction('subAction', 'postMessage', index); } + if (!subActionParams) { editAction( 'subActionParams', { channels, channelIds, + channelNames, text, }, index ); } - const typeChannelInput = useMemo(() => { - if (channels.length > 0 && channelIds.length === 0) { - return 'channel-name'; - } else if ( - ( - (actionConnector as UserConfiguredActionConnector)?.config - ?.allowedChannels ?? [] - ).length > 0 - ) { - return 'channel-allowed-ids'; - } - return 'channel-id'; - }, [actionConnector, channelIds.length, channels.length]); - - const slackChannelsOptions = useMemo(() => { - return ( - (actionConnector as UserConfiguredActionConnector)?.config - ?.allowedChannels ?? [] - ).map((ac) => ({ - label: `${ac.id} - ${ac.name}`, - value: ac.id, - 'data-test-subj': ac.id, - })); - }, [actionConnector]); const onChangeComboBox = useCallback( - (newOptions: EuiComboBoxOptionOption[]) => { + (newOptions: EuiComboBoxOptionOption[]) => { const newSelectedChannels = newOptions.map((option) => option.value!.toString()); setSelectedChannels(newOptions); + editAction( 'subActionParams', - { channels: undefined, channelIds: newSelectedChannels, text }, + { channels: undefined, channelIds: undefined, channelNames: newSelectedChannels, text }, index ); }, [editAction, index, text] ); - const onBlurChannelIds = useCallback(() => { - if (tempChannelId === '') { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); + + const onCreateOption = (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; } - setValidChannelId(tempChannelId.trim()); - }, [editAction, index, tempChannelId, text]); - const onChangeTextField = useCallback( - (evt: React.ChangeEvent) => { - editAction('subActionParams', { channels: undefined, channelIds: [], text }, index); - setTempChannelId(evt.target.value); - }, - [editAction, index, text] - ); + const newOption = { + label: searchValue, + }; + + setSelectedChannels([newOption]); + }; const channelInput = useMemo(() => { - if (typeChannelInput === 'channel-name' || typeChannelInput === 'channel-id') { - return ( - 0} - fullWidth={true} - /> - ); - } return ( ); - }, [ - channelValidError.length, - isValidatingChannel, - onBlurChannelIds, - onChangeComboBox, - onChangeTextField, - selectedChannels, - slackChannelsOptions, - tempChannelId, - typeChannelInput, - ]); + }, [onChangeComboBox, selectedChannels, shouldAllowAnyChannel, slackChannelsOptions]); return ( <> 0 ? channelValidError : (errors.channels as string[])} - isInvalid={Number(errors.channels?.length) > 0 || channelValidError.length > 0} - helpText={ - channelIds.length > 0 && channelValidInfo - ? `${channelValidInfo.id} - ${channelValidInfo.name}` - : '' - } + error={errors.channels as string[]} + isInvalid={Number(errors.channels?.length) > 0} + id={channelsLabelId} > {channelInput} @@ -380,3 +269,33 @@ const SlackParamsFields: React.FunctionComponent< // eslint-disable-next-line import/no-default-export export { SlackParamsFields as default }; + +const getSelectedChannel = ({ + channels, + channelIds, + channelNames, +}: { + channels?: string[]; + channelIds?: string[]; + channelNames?: string[]; +}) => { + if (channelNames && channelNames.length > 0) { + return channelNames[0]; + } + + if (channelIds && channelIds.length > 0) { + return channelIds[0]; + } + + if (channels && channels.length > 0) { + return channels[0]; + } +}; + +const formatChannel = (channel: string): string => { + if (channel.startsWith('#')) { + return channel; + } + + return `#${channel}`; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts index f08864bda94b9..db3a4b4c9774b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/translations.ts @@ -43,7 +43,7 @@ export const ACTION_TYPE_TITLE = i18n.translate( export const ALLOWED_CHANNELS = i18n.translate( 'xpack.stackConnectors.components.slack_api.allowedChannelsLabel', { - defaultMessage: 'Channel IDs', + defaultMessage: 'Allowed channels', } ); export const SUCCESS_FETCH_CHANNELS = i18n.translate( @@ -83,3 +83,17 @@ export const BLOCKS_REQUIRED = i18n.translate( defaultMessage: `JSON must contain field "blocks".`, } ); + +export const OPTIONAL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.slack_api.optionalLabel', + { + defaultMessage: 'Optional', + } +); + +export const CHANNEL_NAME_ERROR = i18n.translate( + 'xpack.stackConnectors.components.slack_api.channelNameError', + { + defaultMessage: 'Channel name must start with a #', + } +); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx deleted file mode 100644 index 2dc6106e1e617..0000000000000 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/use_valid_channels.tsx +++ /dev/null @@ -1,101 +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 { useCallback, useEffect, useState } from 'react'; -import { useQuery } from '@kbn/react-query'; -import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import type { HttpSetup } from '@kbn/core/public'; -import type { 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 => { - return http.post( - `${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>([]); - 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, - }; -} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 7860eed617b72..e4e28912df306 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server'; +import type { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; @@ -15,11 +15,7 @@ import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-obje import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { registerInferenceConnectorsUsageCollector } from './usage/inference/inference_connectors_usage_collector'; import { registerConnectorTypes } from './connector_types'; -import { - validSlackApiChannelsRoute, - getWellKnownEmailServiceRoute, - getWebhookSecretHeadersKeyRoute, -} from './routes'; +import { getWellKnownEmailServiceRoute, getWebhookSecretHeadersKeyRoute } from './routes'; import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import type { ConfigSchema as StackConnectorsConfigType } from './config'; @@ -38,12 +34,10 @@ export interface ConnectorsPluginsStart { export class StackConnectorsPlugin implements Plugin { - private readonly logger: Logger; private config: StackConnectorsConfigType; readonly experimentalFeatures: ExperimentalFeatures; constructor(context: PluginInitializerContext) { - this.logger = context.logger.get(); this.config = context.config.get(); this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); } @@ -55,7 +49,6 @@ export class StackConnectorsPlugin const awsSesConfig = actions.getActionsConfigurationUtilities().getAwsSesConfig(); getWellKnownEmailServiceRoute(router, awsSesConfig); - validSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger); getWebhookSecretHeadersKeyRoute(router, core.getStartServices); registerConnectorTypes({ diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts index a4c6d4d8c5e02..df595cb4256fe 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts @@ -6,5 +6,4 @@ */ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; -export { validSlackApiChannelsRoute } from './valid_slack_api_channels'; export { getWebhookSecretHeadersKeyRoute } from './get_webhook_secret_headers_key'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts deleted file mode 100644 index 0144c5d4e07c2..0000000000000 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/valid_slack_api_channels.ts +++ /dev/null @@ -1,103 +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. - */ - -import { schema } from '@kbn/config-schema'; -import type { - IRouter, - RequestHandlerContext, - KibanaRequest, - IKibanaResponse, - KibanaResponseFactory, - Logger, -} from '@kbn/core/server'; -import type { AxiosResponse } from 'axios'; -import axios from 'axios'; -import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; -import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; -import { SLACK_URL } from '@kbn/connector-schemas/slack_api'; -import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; - -import type { 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`, - security: { - authz: { - enabled: false, - reason: - "This route is opted out from authorization as it relies on Slack's own authorization.", - }, - }, - validate: { - body: bodySchema, - }, - options: { - access: 'internal', - }, - }, - handler - ); - - async function handler( - ctx: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise { - const { authToken, channelIds } = req.body; - - const axiosInstance = axios.create(); - - const validChannelId = (channelId = ''): Promise> => { - return request({ - axios: axiosInstance, - configurationUtilities, - headers: { - Authorization: `Bearer ${authToken}`, - 'Content-type': 'application/json; charset=UTF-8', - }, - logger, - method: 'get', - url: `${SLACK_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, - }, - }); - } -}; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx index 80133e9b019dd..89c228021fbf3 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.tsx @@ -13,23 +13,27 @@ import { Field, PasswordField, } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { ValidationConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { FIELD_TYPES, getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { i18n } from '@kbn/i18n'; -export interface CommonFieldSchema { +type Validations = Array>; + +export interface CommonFieldSchema { id: string; label: string; helpText?: string | ReactNode; isRequired?: boolean; type?: keyof typeof FIELD_TYPES; euiFieldProps?: Record; + validations?: Validations; } -export interface ConfigFieldSchema extends CommonFieldSchema { +export interface ConfigFieldSchema extends CommonFieldSchema { isUrlField?: boolean; requireTld?: boolean; - defaultValue?: string | string[]; + defaultValue?: T; } export interface SecretsFieldSchema extends CommonFieldSchema { @@ -41,7 +45,6 @@ interface SimpleConnectorFormProps { readOnly: boolean; configFormSchema: ConfigFieldSchema[]; secretsFormSchema: SecretsFieldSchema[]; - configFormSchemaAfterSecrets?: ConfigFieldSchema[]; } type FormRowProps = ConfigFieldSchema & SecretsFieldSchema & { readOnly: boolean }; @@ -50,20 +53,22 @@ const UseTextField = getUseField({ component: Field }); const UseComboBoxField = getUseField({ component: ComboBoxField }); const { emptyField, urlField } = fieldValidators; -const getFieldConfig = ({ +const getFieldConfig = ({ label, isRequired = true, isUrlField = false, requireTld = true, defaultValue, type, + validations = [], }: { label: string; isRequired?: boolean; isUrlField?: boolean; requireTld?: boolean; - defaultValue?: string | string[]; + defaultValue?: T; type?: keyof typeof FIELD_TYPES; + validations?: Validations; }) => ({ label, validations: [ @@ -97,6 +102,7 @@ const getFieldConfig = ({ }, ] : []), + ...validations, ], defaultValue, ...(type && FIELD_TYPES[type] @@ -124,6 +130,7 @@ const FormRow: React.FC = ({ euiFieldProps = {}, type, requireTld, + validations = [], }) => { const dataTestSub = `${id}-input`; const UseField = getComponentByType(type); @@ -141,6 +148,7 @@ const FormRow: React.FC = ({ type, isRequired, requireTld, + validations, })} helpText={helpText} componentProps={{ @@ -155,7 +163,7 @@ const FormRow: React.FC = ({ ) : ( = ({ readOnly, configFormSchema, secretsFormSchema, - configFormSchemaAfterSecrets = [], }) => { return ( <> @@ -214,12 +221,6 @@ const SimpleConnectorFormComponent: React.FC = ({ {index !== secretsFormSchema.length ? : null} ))} - {configFormSchemaAfterSecrets.map(({ id, ...restConfigSchemaAfterSecrets }, index) => ( - - - {index !== configFormSchemaAfterSecrets.length ? : null} - - ))} ); }; From dfac6b926fd2782ddf42deb89108eae0fd59a87a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 9 Dec 2025 17:35:07 +0100 Subject: [PATCH 03/12] Small fixes --- .../src/common/types/action_types.ts | 19 ++---- .../connector_types/slack_api/slack_api.tsx | 29 +++++---- .../slack_api/slack_connectors.tsx | 1 - .../slack_api/slack_params.tsx | 64 +++++++++++++------ 4 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts index f62c39e3b23dc..ebf3441348c76 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/types/action_types.ts @@ -86,12 +86,11 @@ export type ConnectorFormSchema< > & Partial, 'id' | 'name'>>; -export type InternalConnectorForm = Record> = - ConnectorFormSchema & { - __internal__?: { - headers?: Array<{ key: string; value: string; type: string }>; - }; +export type InternalConnectorForm = ConnectorFormSchema & { + __internal__?: { + headers?: Array<{ key: string; value: string; type: string }>; }; +}; export interface ActionParamsProps { actionParams: Partial; @@ -160,18 +159,12 @@ export interface ActionTypeModel - >; + deserializer?: SerializerFunc; /** * Form hook lib serializer used in the connector form * Use this to transform the intermediate state used in the form into a connector object */ - serializer?: SerializerFunc< - ConnectorFormSchema, - InternalConnectorForm - >; + serializer?: SerializerFunc; /** * If true, hides the settings title of the connector form * @default false diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 74d42e5db3fac..8ba2de6d3855b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -31,14 +31,19 @@ import type { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; const isChannelValid = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { - if ( - (channels === undefined && !channelIds?.length) || - (channelIds === undefined && !channels?.length) || - (!channelIds?.length && !channels?.length) - ) { - return false; + if (channelNames && channelNames.length > 0) { + return true; } - return true; + + if (channelIds && channelIds.length > 0) { + return true; + } + + if (channels && channels.length > 0) { + return true; + } + + return false; }; export const getConnectorType = (): ConnectorTypeModel< @@ -111,7 +116,8 @@ export const getConnectorType = (): ConnectorTypeModel< }, connectorForm: { serializer: (data) => { - const formAllowedChannels = data.config?.allowedChannels ?? []; + const formAllowedChannels = + (data.config?.allowedChannels as SlackApiConfig['allowedChannels']) ?? []; const allowedChannels = formAllowedChannels.map((option) => ({ name: option })) ?? []; @@ -121,8 +127,9 @@ export const getConnectorType = (): ConnectorTypeModel< }; }, deserializer: (data) => { - const allowedChannels = - data.config?.allowedChannels?.map((channel) => { + const allowedChannels = data.config?.allowedChannels as Array<{ name: string }>; + const formattedChannels = + allowedChannels.map((channel) => { if (channel.name.startsWith('#')) { return channel.name; } @@ -132,7 +139,7 @@ export const getConnectorType = (): ConnectorTypeModel< return { ...data, - config: { ...data.config, allowedChannels }, + config: { ...data.config, allowedChannels: formattedChannels }, }; }, }, diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index b6d644ae089d3..5b1ab1623c894 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -16,7 +16,6 @@ import { EuiLink, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { DocLinksStart } from '@kbn/core/public'; -import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import * as i18n from './translations'; const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => [ diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index a485aed2cec3f..33a60e353e95f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -45,7 +45,11 @@ const SlackParamsFields: React.FunctionComponent< const { subAction, subActionParams } = actionParams; const { channels = [], text, channelIds = [], channelNames = [] } = subActionParams ?? {}; - const selectedChannel = getSelectedChannel({ channels, channelIds, channelNames }); + const selectedChannel = getSelectedChannel({ + channels, + channelIds, + channelNames, + }); const [messageType, setMessageType] = useState('text'); const [textValue, setTextValue] = useState(text); @@ -60,7 +64,7 @@ const SlackParamsFields: React.FunctionComponent< 'data-test-subj': ac.name, })); - const shouldAllowAnyChannel = !Boolean(slackChannelsOptions.length); + const hasAllowedChannelsConfig = Boolean(allowedChannelsConfig.length); const [selectedChannels, setSelectedChannels] = useState[]>( selectedChannel ? [{ value: selectedChannel, label: selectedChannel }] : [] @@ -98,7 +102,7 @@ const SlackParamsFields: React.FunctionComponent< if (connectorId && connectorId !== actionConnector?.id) { editAction( 'subActionParams', - { channels: undefined, channelIds: undefined, channelNames: [], text }, + { channels: undefined, channelIds: undefined, channelNames: undefined, text }, index ); setSelectedChannels([]); @@ -139,18 +143,32 @@ const SlackParamsFields: React.FunctionComponent< [editAction, index, text] ); - const onCreateOption = (searchValue: string) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); + const onCreateOption = useCallback( + (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); - if (!normalizedSearchValue) { - return; - } + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; - const newOption = { - label: searchValue, - }; + setSelectedChannels([newOption]); - setSelectedChannels([newOption]); + editAction( + 'subActionParams', + { channels: undefined, channelIds: undefined, channelNames: [newOption], text }, + index + ); + }, + [editAction, index, text] + ); + + const onTextChange = (value: string) => { + setTextValue(value); + editAction('subActionParams', { channels, channelIds, channelNames, text: value }, index); }; const channelInput = useMemo(() => { @@ -158,16 +176,22 @@ const SlackParamsFields: React.FunctionComponent< ); - }, [onChangeComboBox, selectedChannels, shouldAllowAnyChannel, slackChannelsOptions]); + }, [ + hasAllowedChannelsConfig, + onChangeComboBox, + onCreateOption, + selectedChannels, + slackChannelsOptions, + ]); return ( <> @@ -213,8 +237,7 @@ const SlackParamsFields: React.FunctionComponent< { - setTextValue(value); - editAction('subActionParams', { channels, channelIds, text: value }, index); + onTextChange(value); }} messageVariables={messageVariables} paramsProperty="webApiText" @@ -230,10 +253,7 @@ const SlackParamsFields: React.FunctionComponent< ) : ( <> { - setTextValue(json); - editAction('subActionParams', { channels, channelIds, text: json }, index); - }} + onDocumentsChange={onTextChange} messageVariables={messageVariables} paramsProperty="webApiBlock" inputTargetValue={textValue} @@ -290,6 +310,8 @@ const getSelectedChannel = ({ if (channels && channels.length > 0) { return channels[0]; } + + return null; }; const formatChannel = (channel: string): string => { From 2248a906e1dc9c57508ba170e54557d20e5d1653 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 10 Dec 2025 13:49:58 +0100 Subject: [PATCH 04/12] Add tests for creating/editing connector --- .../slack_api/form_deserializer.test.tsx | 53 ++++ .../slack_api/form_deserializer.ts | 27 ++ .../slack_api/form_serializer.test.tsx | 54 ++++ .../slack_api/form_serializer.ts | 18 ++ .../slack_api/slack_api.test.tsx | 33 +- .../connector_types/slack_api/slack_api.tsx | 31 +- .../slack_api/slack_connectors.test.tsx | 299 ++++++++++++------ .../slack_api/slack_connectors.tsx | 32 +- 8 files changed, 392 insertions(+), 155 deletions(-) create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.test.tsx create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.test.tsx create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.ts diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.test.tsx new file mode 100644 index 0000000000000..ad5e047d37771 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registerConnectorTypes } from '..'; +import { experimentalFeaturesMock, registrationServicesMock } from '../../mocks'; +import { CONNECTOR_ID } from '@kbn/connector-schemas/slack_api/constants'; +import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; + +let connectorTypeModel: ConnectorTypeModel; + +beforeAll(async () => { + const connectorTypeRegistry = new TypeRegistry(); + ExperimentalFeaturesService.init({ experimentalFeatures: experimentalFeaturesMock }); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + + const getResult = connectorTypeRegistry.get(CONNECTOR_ID); + + if (getResult !== null) { + connectorTypeModel = getResult; + } +}); + +describe('deserializer', () => { + it('should deserialize the config as expected', () => { + const data = { + id: '1', + name: 'slack api', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + actionTypeId: CONNECTOR_ID, + config: { allowedChannels: [{ name: 'general' }, { name: '#random' }] }, + secrets: {}, + }; + + const deserializer = connectorTypeModel.connectorForm?.deserializer!; + const result = deserializer(data); + + expect(result).toEqual({ + ...data, + config: { + ...data.config, + allowedChannels: ['#general', '#random'], + }, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.ts new file mode 100644 index 0000000000000..5226204eb6dff --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_deserializer.ts @@ -0,0 +1,27 @@ +/* + * 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 type { ConnectorFormSchema, InternalConnectorForm } from '@kbn/alerts-ui-shared'; +import type { SlackApiConfig } from '@kbn/connector-schemas/slack_api'; + +export const deserializer = (data: ConnectorFormSchema): InternalConnectorForm => { + const allowedChannels = (data.config?.allowedChannels as SlackApiConfig['allowedChannels']) ?? []; + + const formattedChannels = + allowedChannels.map((channel) => { + if (channel.name.startsWith('#')) { + return channel.name; + } + + return `#${channel.name}`; + }) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels: formattedChannels }, + }; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.test.tsx new file mode 100644 index 0000000000000..acd6107873cb2 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registerConnectorTypes } from '..'; +import { experimentalFeaturesMock, registrationServicesMock } from '../../mocks'; +import { CONNECTOR_ID } from '@kbn/connector-schemas/slack_api/constants'; +import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; + +let connectorTypeModel: ConnectorTypeModel; + +beforeAll(async () => { + const connectorTypeRegistry = new TypeRegistry(); + ExperimentalFeaturesService.init({ experimentalFeatures: experimentalFeaturesMock }); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + + const getResult = connectorTypeRegistry.get(CONNECTOR_ID); + + if (getResult !== null) { + connectorTypeModel = getResult; + } +}); + +describe('serializer', () => { + it('should serialize the config as expected', () => { + const data = { + id: '1', + name: 'slack api', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + actionTypeId: CONNECTOR_ID, + config: { allowedChannels: ['#general', '#random'] }, + secrets: {}, + }; + + const serializer = connectorTypeModel.connectorForm?.serializer!; + + const result = serializer(data); + + expect(result).toEqual({ + ...data, + config: { + ...data.config, + allowedChannels: [{ name: '#general' }, { name: '#random' }], + }, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.ts new file mode 100644 index 0000000000000..7467b79116f42 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/form_serializer.ts @@ -0,0 +1,18 @@ +/* + * 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 type { ConnectorFormSchema, InternalConnectorForm } from '@kbn/alerts-ui-shared'; + +export const serializer = (data: InternalConnectorForm): ConnectorFormSchema => { + const formAllowedChannels = (data.config?.allowedChannels as string[]) ?? []; + const allowedChannels = formAllowedChannels.map((option) => ({ name: option })) ?? []; + + return { + ...data, + config: { ...data.config, allowedChannels }, + }; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx index b2c1d36f1c3c0..44dd6b28f9ecf 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx @@ -24,6 +24,7 @@ const testBlock = { }, ], }; + beforeAll(async () => { const connectorTypeRegistry = new TypeRegistry(); ExperimentalFeaturesService.init({ experimentalFeatures: experimentalFeaturesMock }); @@ -35,14 +36,14 @@ beforeAll(async () => { }); describe('connectorTypeRegistry.get works', () => { - test('connector type static data is as expected', () => { + it('connector type static data is as expected', () => { expect(connectorTypeModel.id).toEqual(CONNECTOR_ID); expect(connectorTypeModel.iconClass).toEqual('logoSlack'); }); }); describe('hideInUi', () => { - test('should return true when slack is enabled in config', () => { + it('should return true when slack is enabled in config', () => { expect( // @ts-expect-error connectorTypeModel.getHideInUi([ @@ -52,7 +53,7 @@ describe('hideInUi', () => { ).toEqual(true); }); - test('should return false when slack is disabled in config', () => { + it('should return false when slack is disabled in config', () => { expect( // @ts-expect-error connectorTypeModel.getHideInUi([ @@ -62,7 +63,7 @@ describe('hideInUi', () => { ).toEqual(false); }); - test('should return false when slack is not found in config', () => { + it('should return false when slack is not found in config', () => { expect( // @ts-expect-error connectorTypeModel.getHideInUi([ @@ -75,7 +76,7 @@ describe('hideInUi', () => { describe('Slack action params validation', () => { describe('postMessage', () => { - test('should succeed when action params include valid message and channels list', async () => { + it('should succeed when action params include valid message and channels list', async () => { const actionParams = { subAction: 'postMessage', subActionParams: { channels: ['general'], text: 'some text' }, @@ -89,7 +90,7 @@ describe('Slack action params validation', () => { }); }); - test('should succeed when action params include valid message and channels and channel ids', async () => { + it('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' }, @@ -103,7 +104,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when channels field is missing in action params', async () => { + it('should fail when channels field is missing in action params', async () => { const actionParams = { subAction: 'postMessage', subActionParams: { text: 'some text' }, @@ -117,7 +118,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when field text does not exist', async () => { + it('should fail when field text does not exist', async () => { const actionParams = { subAction: 'postMessage', subActionParams: { channels: ['general'] }, @@ -131,7 +132,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when text is empty string', async () => { + it('should fail when text is empty string', async () => { const actionParams = { subAction: 'postMessage', subActionParams: { channels: ['general'], text: '' }, @@ -147,7 +148,7 @@ describe('Slack action params validation', () => { }); describe('postBlockkit', () => { - test('should succeed when action params include valid JSON message and channels list', async () => { + it('should succeed when action params include valid JSON message and channels list', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { channels: ['general'], text: JSON.stringify(testBlock) }, @@ -161,7 +162,7 @@ describe('Slack action params validation', () => { }); }); - test('should succeed when action params include valid JSON message and channels and channel ids', async () => { + it('should succeed when action params include valid JSON message and channels and channel ids', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { @@ -179,7 +180,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when channels field is missing in action params', async () => { + it('should fail when channels field is missing in action params', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { text: JSON.stringify(testBlock) }, @@ -193,7 +194,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when field text does not exist', async () => { + it('should fail when field text does not exist', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { channels: ['general'] }, @@ -207,7 +208,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when text is empty string', async () => { + it('should fail when text is empty string', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { channels: ['general'], text: '' }, @@ -221,7 +222,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when text is invalid JSON', async () => { + it('should fail when text is invalid JSON', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { channels: ['general'], text: 'abcd' }, @@ -235,7 +236,7 @@ describe('Slack action params validation', () => { }); }); - test('should fail when text is JSON but does not contain "blocks" property', async () => { + it('should fail when text is JSON but does not contain "blocks" property', async () => { const actionParams = { subAction: 'postBlockkit', subActionParams: { channels: ['general'], text: JSON.stringify({ foo: 'bar' }) }, diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 8ba2de6d3855b..b897b1e04ab18 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -29,6 +29,8 @@ import { } from './translations'; import type { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; +import { serializer } from './form_serializer'; +import { deserializer } from './form_deserializer'; const isChannelValid = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { if (channelNames && channelNames.length > 0) { @@ -115,32 +117,7 @@ export const getConnectorType = (): ConnectorTypeModel< return {}; }, connectorForm: { - serializer: (data) => { - const formAllowedChannels = - (data.config?.allowedChannels as SlackApiConfig['allowedChannels']) ?? []; - - const allowedChannels = formAllowedChannels.map((option) => ({ name: option })) ?? []; - - return { - ...data, - config: { ...data.config, allowedChannels }, - }; - }, - deserializer: (data) => { - const allowedChannels = data.config?.allowedChannels as Array<{ name: string }>; - const formattedChannels = - allowedChannels.map((channel) => { - if (channel.name.startsWith('#')) { - return channel.name; - } - - return `#${channel.name}`; - }) ?? []; - - return { - ...data, - config: { ...data.config, allowedChannels: formattedChannels }, - }; - }, + serializer, + deserializer, }, }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx index 95679d5795dcb..d3a528a2d4d6d 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -6,150 +6,253 @@ */ import React from 'react'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; +import { render, screen, waitFor } from '@testing-library/react'; import { createStartServicesMock } from '@kbn/triggers-actions-ui-plugin/public/common/lib/kibana/kibana_react.mock'; -import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; +import { ConnectorFormTestProvider } from '../lib/test_utils'; import { SlackActionFieldsComponents as SlackActionFields } from './slack_connectors'; -import { useValidChannels } from './use_valid_channels'; +import userEvent from '@testing-library/user-event'; +import { serializer } from './form_serializer'; +import { deserializer } from './form_deserializer'; const mockUseKibanaReturnValue = createStartServicesMock(); + jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana', () => ({ __esModule: true, useKibana: jest.fn(() => ({ services: mockUseKibanaReturnValue, })), })); -jest.mock('./use_valid_channels'); - -(useKibana as jest.Mock).mockImplementation(() => ({ - services: { - docLinks: { - links: { - alerting: { slackApiAction: 'url' }, - }, - }, - notifications: { - toasts: { - addSuccess: jest.fn(), - addDanger: jest.fn(), - }, - }, - }, -})); - -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 () => { + describe('creating new connector', () => { const actionConnector = { - secrets: { - token: 'some token', - }, + secrets: {}, config: {}, id: 'test', - actionTypeId: '.slack', + actionTypeId: '.slack-api', name: 'slack', isDeprecated: false, }; - render( - - {}} /> - - ); + it('all connector fields is rendered for web_api type', async () => { + render( + + {}} + /> + + ); - expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); - expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); - }); + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + }); - it('connector validation succeeds when connector config is valid for Web API type', async () => { - const actionConnector = { - secrets: { - token: 'some token', - }, - id: 'test', - actionTypeId: '.slack', - name: 'slack', - config: {}, - isDeprecated: false, - }; + it('creates a new connector correctly', async () => { + render( + + {}} + /> + + ); - // Simulate that user just type a channel ID - useValidChannelsMock.mockImplementation(() => ({ - channels: ['my-channel'], - isLoading: false, - resetChannelsToValidate: jest.fn(), - })); - - render( - - {}} /> - - ); - await waitForComponentToUpdate(); - act(() => { - screen.getByTestId('form-test-provide-submit').click(); - }); + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('secrets.token-input')); + await userEvent.paste('some token'); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), '#general{enter}'); - await waitFor(() => { - expect(onSubmit).toBeCalledTimes(1); - expect(onSubmit).toBeCalledWith({ - data: { - secrets: { - token: 'some token', + await userEvent.click(screen.getByTestId('form-test-provide-submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith({ + data: { + ...actionConnector, + secrets: { + token: 'some token', + }, + config: { + allowedChannels: [{ name: '#general' }], + }, }, - config: { - allowedChannels: ['my-channel'], + isValid: true, + }); + }); + }); + + it('can create a connector without allowed channels', async () => { + render( + + {}} + /> + + ); + + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('secrets.token-input')); + await userEvent.paste('some token'); + + await userEvent.click(screen.getByTestId('form-test-provide-submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith({ + data: { + ...actionConnector, + secrets: { + token: 'some token', + }, + config: { + allowedChannels: [], + }, }, - id: 'test', - actionTypeId: '.slack', - name: 'slack', - isDeprecated: false, - }, - isValid: true, + isValid: true, + }); + }); + }); + + it('validates allowed channels correctly', async () => { + render( + + {}} + /> + + ); + + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('secrets.token-input')); + await userEvent.paste('some token'); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), 'general{enter}'); + + await userEvent.click(screen.getByTestId('form-test-provide-submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith(expect.objectContaining({ isValid: false })); }); }); }); - it('connector validation should failed when allowedChannels is empty', async () => { + describe('editing connector', () => { const actionConnector = { secrets: { token: 'some token', }, + config: { allowedChannels: [{ id: 'channel-id', name: '#test' }] }, id: 'test', - actionTypeId: '.slack', + actionTypeId: '.slack-api', name: 'slack', - config: {}, isDeprecated: false, }; - render( - - {}} /> - - ); - await waitForComponentToUpdate(); - act(() => { - screen.getByTestId('form-test-provide-submit').click(); + it('renders correctly the values of the connector', async () => { + render( + + {}} + /> + + ); + + expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + expect(screen.getByText('#test')).toBeInTheDocument(); }); - await waitFor(() => { - expect(onSubmit).toBeCalledTimes(1); - expect(onSubmit).toBeCalledWith(expect.objectContaining({ isValid: false })); + it('changes the values correctly', async () => { + render( + + {}} + /> + + ); + + await userEvent.clear(screen.getByTestId('secrets.token-input')); + await userEvent.click(screen.getByTestId('secrets.token-input')); + await userEvent.paste('token updated'); + + await userEvent.click(screen.getByTestId('comboBoxClearButton')); + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), '#new-channel{enter}'); + + await userEvent.click(screen.getByTestId('form-test-provide-submit')); + + await waitFor(() => { + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith({ + data: { + ...actionConnector, + secrets: { + token: 'token updated', + }, + config: { + allowedChannels: [{ name: '#new-channel' }], + }, + }, + isValid: true, + }); + }); }); }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 5b1ab1623c894..87018e0d23680 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -42,21 +42,25 @@ const getConfigFormSchema = (): ConfigFieldSchema[] => [ type: 'COMBO_BOX', validations: [ { - isBlocking: false, + isBlocking: true, validator: (args) => { - // console.log('args.value', args.value, typeof args.value); - // // const areAllValid = args.value.every((value) => value.startsWith('#')); - // // if (areAllValid) { - // // return; - // // } - // if (args.value.startsWith('#')) { - // return; - // } - // return { - // code: 'ERR_FIELD_FORMAT', - // formatType: 'COMBO_BOX', - // message: i18n.CHANNEL_NAME_ERROR, - // }; + const valueAsArray = Array.isArray(args.value) ? args.value : [args.value]; + + if (valueAsArray.length === 0) { + return; + } + + const areAllValid = valueAsArray.every((value) => value.startsWith('#')); + + if (areAllValid) { + return; + } + + return { + code: 'ERR_FIELD_FORMAT', + formatType: 'COMBO_BOX', + message: i18n.CHANNEL_NAME_ERROR, + }; }, }, ], From 28901a030aed399cddc0f2fd4d5984b20ae2e6c5 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 15 Dec 2025 16:31:57 +0200 Subject: [PATCH 05/12] Fix bugs and add tests --- .../slack_api/slack_api.test.tsx | 100 ++ .../connector_types/slack_api/slack_api.tsx | 68 +- .../slack_api/slack_connectors.test.tsx | 4 +- .../slack_api/slack_params.test.tsx | 862 +++++++++--------- .../slack_api/slack_params.tsx | 253 ++--- .../edit_connector_flyout/index.tsx | 5 +- 6 files changed, 763 insertions(+), 529 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx index 44dd6b28f9ecf..27840fc315c9f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx @@ -250,4 +250,104 @@ describe('Slack action params validation', () => { }); }); }); + + describe('channels', () => { + it('should fail when all channel variables are undefined', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: ['Channel ID is required.'], + }, + }); + }); + + it('should fail when all channel variables are empty', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text', channelNames: [], channels: [], channelIds: [] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: ['Channel ID is required.'], + }, + }); + }); + + it('should not fail when channelNames is set', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text', channelNames: ['#test'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: [], + }, + }); + }); + + it('should not fail when channelIds is set', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text', channelIds: ['channel-id'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: [], + }, + }); + }); + + it('should not fail when channels is set', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text', channels: ['my-channel'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: [], + }, + }); + }); + + it('should not fail if channel names do not start with #', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text', channelNames: ['test'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ + errors: { + text: [], + channels: ['Channel name must start with a #'], + }, + }); + }); + }); + + describe('default values', () => { + it('sets the default values as expected', () => { + expect(connectorTypeModel.defaultActionParams).toEqual({ + subAction: 'postMessage', + subActionParams: { text: undefined }, + }); + + expect(connectorTypeModel.defaultRecoveredActionParams).toEqual({ + subAction: 'postMessage', + subActionParams: { text: undefined }, + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index b897b1e04ab18..73a6d82ad9ff7 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -26,26 +26,56 @@ import { SELECT_MESSAGE, JSON_REQUIRED, BLOCKS_REQUIRED, + CHANNEL_NAME_ERROR, } from './translations'; import type { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; import { serializer } from './form_serializer'; import { deserializer } from './form_deserializer'; -const isChannelValid = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { - if (channelNames && channelNames.length > 0) { - return true; +const DEFAULT_PARAMS = { subAction: 'postMessage' as const, subActionParams: { text: undefined } }; + +const getChannelErrors = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { + const isDefinedAndEmptyArray = (arr?: string[]) => { + if (arr && arr.length === 0) { + return true; + } + + return false; + }; + + const allChannels = [channels, channelIds, channelNames]; + const allUndefined = allChannels.every((channelVariable) => !Boolean(channelVariable)); + + const isChannelNamesEmpty = isDefinedAndEmptyArray(channelNames); + const isChannelIdsEmpty = isDefinedAndEmptyArray(channelIds); + const isChannelsEmpty = isDefinedAndEmptyArray(channels); + + if (allUndefined) { + return [CHANNEL_REQUIRED]; + } + + if (isChannelNamesEmpty) { + return [CHANNEL_REQUIRED]; } - if (channelIds && channelIds.length > 0) { - return true; + if (isChannelNamesEmpty && isChannelIdsEmpty) { + return [CHANNEL_REQUIRED]; } - if (channels && channels.length > 0) { - return true; + if (isChannelIdsEmpty && isChannelsEmpty) { + return [CHANNEL_REQUIRED]; } - return false; + if ( + channelNames && + channelNames.length > 0 && + channelNames.some((channelName) => !channelName.startsWith('#')) + ) { + return [CHANNEL_NAME_ERROR]; + } + + return []; }; export const getConnectorType = (): ConnectorTypeModel< @@ -70,19 +100,22 @@ export const getConnectorType = (): ConnectorTypeModel< text: new Array(), channels: new Array(), }; + const validationResult = { errors }; + if (actionParams.subAction === 'postMessage' || actionParams.subAction === 'postBlockkit') { if (!actionParams.subActionParams.text) { errors.text.push(MESSAGE_REQUIRED); } - if ( - !isChannelValid( - actionParams.subActionParams.channels, - actionParams.subActionParams.channelIds, - actionParams.subActionParams.channelNames - ) - ) { - errors.channels.push(CHANNEL_REQUIRED); + + const channelErrors = getChannelErrors( + actionParams.subActionParams.channels, + actionParams.subActionParams.channelIds, + actionParams.subActionParams.channelNames + ); + + if (channelErrors.length > 0) { + errors.channels.push(...channelErrors); } if (actionParams.subAction === 'postBlockkit' && actionParams.subActionParams.text) { @@ -96,6 +129,7 @@ export const getConnectorType = (): ConnectorTypeModel< } } } + return validationResult; }, actionConnectorFields: lazy(() => import('./slack_connectors')), @@ -120,4 +154,6 @@ export const getConnectorType = (): ConnectorTypeModel< serializer, deserializer, }, + defaultActionParams: DEFAULT_PARAMS, + defaultRecoveredActionParams: DEFAULT_PARAMS, }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx index d3a528a2d4d6d..709c4126435d3 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -36,7 +36,7 @@ describe('SlackActionFields renders', () => { secrets: {}, config: {}, id: 'test', - actionTypeId: '.slack-api', + actionTypeId: '.slack_api', name: 'slack', isDeprecated: false, }; @@ -187,7 +187,7 @@ describe('SlackActionFields renders', () => { }, config: { allowedChannels: [{ id: 'channel-id', name: '#test' }] }, id: 'test', - actionTypeId: '.slack-api', + actionTypeId: '.slack_api', name: 'slack', isDeprecated: false, }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx index 661f09d6dbd7a..97f395c70f7a5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -6,53 +6,20 @@ */ import React from 'react'; -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor } 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 type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; +import type { AppMockRenderer } from '../lib/test_utils'; +import { createAppMockRenderer } from '../lib/test_utils'; import userEvent from '@testing-library/user-event'; -interface Result { - isLoading: boolean; - response: Record; - error: null | Error; -} - const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public'; -const mockUseValidChanelId = jest.fn().mockImplementation(() => ({ - isLoading: false, - response: { - channel: { - id: 'id', - name: 'general', - is_channel: true, - is_archived: false, - is_private: true, - }, - }, - error: null, -})); -const testBlock = { - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: "Hello, Assistant to the Regional Manager Dwight! *Michael Scott* wants to know where you'd like to take the Paper Company investors to dinner tonight.\n", - }, - }, - ], -}; -const mockUseSubAction = jest.fn]>(mockUseValidChanelId); - const mockToasts = { addDanger: jest.fn(), addWarning: jest.fn() }; + jest.mock(triggersActionsPath, () => { const original = jest.requireActual(triggersActionsPath); return { ...original, - useSubAction: (params: UseSubActionParams) => mockUseSubAction(params), useKibana: () => ({ ...original.useKibana(), services: { @@ -62,439 +29,520 @@ 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, +describe('SlackParamsFields', () => { + const actionConnector = { + secrets: {}, + config: {}, + id: 'test', + actionTypeId: '.slack_api', + name: 'slack', + isDeprecated: false, + isPreconfigured: false as const, + isSystemAction: false, + isConnectorTypeDeprecated: false, + }; + + const actionConnectorWithAllowedList = { + ...actionConnector, + id: 'test-2', + config: { allowedChannels: [{ name: '#test' }, { name: '#general' }] }, + }; + + const actionParams = { + subAction: 'postMessage' as const, + subActionParams: { + channels: ['my-channel'], + channelIds: ['my-channel-id'], + channelNames: ['my-channel-name'], + text: 'a new slack message', + }, + }; + + const testBlock = JSON.stringify({ + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Some **text**', }, }, - 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( - - - + + const editAction = jest.fn(); + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('changing connector resets the fields', async () => { + const { rerender } = appMockRenderer.render( + ); + expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); - expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('some text'); + + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); + rerender( - - - - ); - expect(editAction).toHaveBeenCalledWith( - 'subActionParams', - { channels: ['general'], channelIds: [], text: 'some different default message' }, - 0 + ); + + expect(screen.getByTestId('webApiTextTextArea')).toHaveValue(''); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: undefined }, + 0 + ); + }); }); - test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed, Web API', () => { - const editAction = jest.fn(); - const { rerender } = render( - - - + it('changing the index resets the fields', async () => { + const { rerender } = appMockRenderer.render( + ); + expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); - expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('some text'); + + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); rerender( - - - + ); - expect(editAction).not.toHaveBeenCalled(); + + expect(screen.getByTestId('webApiTextTextArea')).toHaveValue(''); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: undefined }, + 0 + ); + }); }); - test('default to text field when no existing subaction params', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - + it('changing the connector resets the fields to the new params', async () => { + const { rerender } = appMockRenderer.render( + ); expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); - expect(screen.getByTestId('webApiTextTextArea')).toHaveValue(''); - }); - test('correctly renders params fields for postMessage subaction', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); + + rerender( + ); - expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); - expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); - expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('some text'); + expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('a new slack message'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subActionParams', actionParams.subActionParams, 0); + }); }); - test('correctly renders params fields for postBlockkit subaction', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - + it('uses the default value if the text is not defined', async () => { + appMockRenderer.render( + ); - expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); - expect(screen.getByTestId('webApiBlock')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('my default message'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: 'my default message' }, + 0 + ); + }); }); - test('should toggle subaction when button group clicked', async () => { - const mockEditFunc = jest.fn(); - const { getByTestId } = render( - - - + it('does not uses the default value if text is defined', async () => { + appMockRenderer.render( + ); - getByTestId('blockkit').click(); - expect(mockEditFunc).toBeCalledWith('subAction', 'postBlockkit', 0); + expect(screen.getByTestId('webApiTextTextArea')).toHaveValue('a new slack message'); - getByTestId('text').click(); - expect(mockEditFunc).toBeCalledWith('subAction', 'postMessage', 0); + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subActionParams', actionParams.subActionParams, 0); + }); }); - test('show the Channel label when using the old attribute "channels" in subActionParams', async () => { - const mockEditFunc = jest.fn(); - const WrappedComponent = () => { - return ( - - - - ); - }; - const { getByTestId } = render(); + it('sets the inital values on first render correctly', async () => { + appMockRenderer.render( + + ); - expect(screen.findByText('Channel')).toBeTruthy(); - expect(screen.getByTestId('slackApiChannelId')).toBeInTheDocument(); - expect(getByTestId('slackApiChannelId')).toHaveValue('old channel name'); + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subAction', 'postMessage', 0); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: undefined }, + 0 + ); + }); }); - test('show the Channel ID label when using the new attribute "channelIds" in subActionParams', async () => { - const mockEditFunc = jest.fn(); - const WrappedComponent: React.FunctionComponent = () => { - return ( - - - + describe('new params', () => { + const params = {}; + + it('renders the initial fields correctly', () => { + appMockRenderer.render( + ); - }; - const { getByTestId } = render(); - expect(screen.findByText('Channel ID')).toBeTruthy(); - expect(screen.getByTestId('slackApiChannelId')).toBeInTheDocument(); - expect(getByTestId('slackApiChannelId')).toHaveValue('channel-id-xxx'); - }); + expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); + expect(screen.getByTestId('slackChannelsComboBox')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); + }); - test('channel id in 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 ( - - - + it('changes the message type correctly', async () => { + appMockRenderer.render( + ); - }; - const { getByTestId } = render(); - getByTestId('slackApiChannelId').click(); - await userEvent.clear(getByTestId('slackApiChannelId')); - fireEvent.change(getByTestId('slackApiChannelId'), { - target: { value: 'new-channel-id' }, + expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Block Kit')); + + expect(await screen.findByTestId('webApiBlock')).toBeInTheDocument(); }); - await userEvent.tab(); - await waitFor(() => { - expect(mockEditFunc).toBeCalledWith( - 'subActionParams', - { channelIds: ['new-channel-id'], channels: undefined, text: 'some text' }, - 0 + it('fill out all params (text) and submits correctly', async () => { + appMockRenderer.render( + ); - expect(mockUseSubAction).toBeCalledWith({ - connectorId: 'connector-id', - disabled: false, - subAction: 'validChannelId', - subActionParams: { - channelId: 'new-channel-id', - }, + + expect(screen.getByTestId('webApiTextTextArea')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subAction', 'postMessage', 0); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: 'a slack message' }, + 0 + ); }); }); - }); - test('channel id work with combobox when allowedChannels pass in the config attributes', async () => { - const mockEditFunc = jest.fn(); - const WrappedComponent = () => { - return ( - - - + it('fill out all params (blockKit) and submits correctly', async () => { + appMockRenderer.render( + ); - }; - const { getByTestId } = render(); - expect(screen.findByText('Channel ID')).toBeTruthy(); - expect(getByTestId('slackChannelsComboBox')).toBeInTheDocument(); - expect(getByTestId('slackChannelsComboBox').textContent).toBe('channel-id-1 - channel 1'); + expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Block Kit')); + expect(await screen.findByTestId('webApiBlock')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('webApiBlockJsonEditor')); + await userEvent.paste(testBlock); - act(() => { - const combobox = getByTestId('slackChannelsComboBox'); - const inputCombobox = within(combobox).getByTestId('comboBoxSearchInput'); - inputCombobox.click(); + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subAction', 'postBlockkit', 0); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: [], channelIds: [], channelNames: [], text: testBlock }, + 0 + ); + }); }); - 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(); + it('resets the textarea when changing tabs', async () => { + appMockRenderer.render( + + ); + + expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); + + await userEvent.click(screen.getByText('Block Kit')); + expect(await screen.findByTestId('webApiBlock')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Text')); + expect(await screen.findByTestId('webApiTextTextArea')).toHaveValue(''); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subAction', 'postMessage', 0); + }); }); - act(() => { - screen.getByTestId('channel-id-3').click(); + it('changes the subAction correctly when changing tabs', async () => { + appMockRenderer.render( + + ); + + expect(screen.getByTestId('slackMessageTypeChangeButton')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Block Kit')); + expect(await screen.findByTestId('webApiBlock')).toBeInTheDocument(); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('subAction', 'postBlockkit', 0); + }); }); - 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 + it('can set any channel when the allowlist is empty', async () => { + appMockRenderer.render( + ); - expect(mockUseSubAction).toBeCalledWith({ - connectorId: 'connector-id', - disabled: false, - subAction: 'validChannelId', - subActionParams: { channelId: '' }, + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), '#my-channel{enter}'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + channels: undefined, + channelIds: undefined, + channelNames: ['#my-channel'], + text: undefined, + }, + 0 + ); }); }); - }); - test('show error message when no channel is selected', async () => { - render( - + it('cannot set channels not in the allow list', async () => { + appMockRenderer.render( {}} + actionParams={params} + editAction={editAction} index={0} - defaultMessage="default message" - messageVariables={[]} + errors={{ message: [] }} + actionConnector={actionConnectorWithAllowedList} /> - + ); + + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), '#my-channel{enter}'); + + expect(await screen.findByText(`doesn't match any options`)).toBeInTheDocument(); + }); + }); + + describe('editing params', () => { + const actionParamsWithChannels = { + subAction: 'postMessage', + subActionParams: { channels: ['my-channel'] }, + }; + + const actionParamsWithChannelsIds = { + subAction: 'postMessage', + subActionParams: { channelIds: ['my-channel-id'] }, + }; + + const actionParamsWithChannelNames = { + subAction: 'postMessage', + subActionParams: { channelNames: ['my-channel-name'] }, + }; + + const tests: [string, Record, string][] = [ + ['channels', actionParamsWithChannels, 'my-channel'], + ['channelIds', actionParamsWithChannelsIds, 'my-channel-id'], + ['channelNames', actionParamsWithChannelNames, 'my-channel-name'], + ]; + + it.each(tests)( + 'renders the initial fields correctly %s', + async (_, params, expectedChannel) => { + appMockRenderer.render( + + ); + expect(await screen.findByText(expectedChannel)).toBeInTheDocument(); + } + ); + + it.each(tests)( + 'changes to channelNames when editing the channels for %s', + async (_, params, expectedChannel) => { + appMockRenderer.render( + + ); + + expect(await screen.findByText(expectedChannel)).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('comboBoxClearButton')); + await userEvent.click(screen.getByTestId('comboBoxSearchInput')); + await userEvent.type(screen.getByTestId('comboBoxSearchInput'), '#my-channel{enter}'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + channels: undefined, + channelIds: undefined, + channelNames: ['#my-channel'], + text: undefined, + }, + 0 + ); + }); + } + ); + + it.each(tests)( + 'does not change the %s when editing the text field', + async (key, params, expectedChannel) => { + appMockRenderer.render( + + ); + + expect(await screen.findByText(expectedChannel)).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('webApiTextTextArea')); + await userEvent.paste('a slack message'); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { + channels: [], + channelIds: [], + channelNames: [], + /** + * The value below will overwrite one of the values above. + * For each test a different value will be overwritten. + */ + [key]: [expectedChannel], + text: 'a slack message', + }, + 0 + ); + }); + } ); - expect(screen.getByText('my error message')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index 33a60e353e95f..48839697b1686 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -27,9 +27,46 @@ import type { SlackApiConfig, } from '@kbn/connector-schemas/slack_api'; -const SlackParamsFields: React.FunctionComponent< - ActionParamsProps -> = ({ +type ParamsProps = ActionParamsProps; + +const SlackParamsFields: React.FunctionComponent = (props) => { + const { editAction, index, useDefaultMessage, defaultMessage } = props; + const { subActionParams } = props.actionParams; + const { channels, text, channelIds, channelNames } = subActionParams ?? {}; + + const connectorId = props.actionConnector?.id ?? ''; + const key = `${connectorId}:${index}`; + + useEffect(() => { + editAction('subAction', 'postMessage', index); + editAction( + 'subActionParams', + { + channels, + channelIds, + channelNames, + text, + }, + index + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectorId, index]); + + useEffect(() => { + if (shouldUseDefaultValue({ useDefaultMessage, defaultMessage, text })) { + editAction( + 'subActionParams', + { channels, channelIds, channelNames, text: defaultMessage }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage, useDefaultMessage]); + + return ; +}; + +const SlackParamsFieldsComponent: React.FunctionComponent = ({ actionConnector, actionParams, editAction, @@ -41,8 +78,7 @@ const SlackParamsFields: React.FunctionComponent< }) => { const channelsLabelId = useGeneratedHtmlId(); - const [connectorId, setConnectorId] = useState(); - const { subAction, subActionParams } = actionParams; + const { subActionParams } = actionParams; const { channels = [], text, channelIds = [], channelNames = [] } = subActionParams ?? {}; const selectedChannel = getSelectedChannel({ @@ -51,8 +87,12 @@ const SlackParamsFields: React.FunctionComponent< channelNames, }); + const initialTextValue = shouldUseDefaultValue({ useDefaultMessage, defaultMessage, text }) + ? defaultMessage + : text; + const [messageType, setMessageType] = useState('text'); - const [textValue, setTextValue] = useState(text); + const [textValue, setTextValue] = useState(initialTextValue); const allowedChannelsConfig = (actionConnector as UserConfiguredActionConnector)?.config @@ -74,61 +114,12 @@ const SlackParamsFields: React.FunctionComponent< (id: string) => { // clear the text when toggled setTextValue(''); - editAction('subAction', id === 'text' ? 'postMessage' : 'postBlockkit', index); + editAction('subAction', getSubAction(id), index); setMessageType(id); }, - [setMessageType, editAction, index, setTextValue] + [editAction, index] ); - useEffect(() => { - if (useDefaultMessage || !text) { - editAction( - 'subActionParams', - { channels, channelIds, channelNames, text: defaultMessage }, - index - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultMessage, useDefaultMessage]); - - useEffect(() => { - if (subAction) { - setMessageType(subAction === 'postMessage' ? 'text' : 'blockkit'); - } - }, [subAction]); - - useEffect(() => { - // Reset channel names input when we changes connector - if (connectorId && connectorId !== actionConnector?.id) { - editAction( - 'subActionParams', - { channels: undefined, channelIds: undefined, channelNames: undefined, text }, - index - ); - setSelectedChannels([]); - } - - setConnectorId(actionConnector?.id ?? ''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionConnector?.id]); - - if (!subAction) { - editAction('subAction', 'postMessage', index); - } - - if (!subActionParams) { - editAction( - 'subActionParams', - { - channels, - channelIds, - channelNames, - text, - }, - index - ); - } - const onChangeComboBox = useCallback( (newOptions: EuiComboBoxOptionOption[]) => { const newSelectedChannels = newOptions.map((option) => option.value!.toString()); @@ -159,7 +150,7 @@ const SlackParamsFields: React.FunctionComponent< editAction( 'subActionParams', - { channels: undefined, channelIds: undefined, channelNames: [newOption], text }, + { channels: undefined, channelIds: undefined, channelNames: [searchValue], text }, index ); }, @@ -168,6 +159,7 @@ const SlackParamsFields: React.FunctionComponent< const onTextChange = (value: string) => { setTextValue(value); + editAction('subAction', getSubAction(messageType), index); editAction('subActionParams', { channels, channelIds, channelNames, text: value }, index); }; @@ -183,14 +175,25 @@ const SlackParamsFields: React.FunctionComponent< singleSelection={true} fullWidth={true} aria-labelledby="channelsLabelId" + onBlur={() => { + if (!channelNames || channelNames?.length === 0) { + editAction('subActionParams', { channels, channelIds, channelNames: [], text }, index); + } + }} /> ); }, [ + channelIds, + channelNames, + channels, + editAction, hasAllowedChannelsConfig, + index, onChangeComboBox, onCreateOption, selectedChannels, slackChannelsOptions, + text, ]); return ( @@ -234,54 +237,19 @@ const SlackParamsFields: React.FunctionComponent< )} {messageType === 'text' ? ( - { - onTextChange(value); - }} - messageVariables={messageVariables} - paramsProperty="webApiText" - inputTargetValue={textValue} - label={i18n.translate( - 'xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', - { - defaultMessage: 'Message', - } - )} + onChange={onTextChange} + value={textValue} errors={(errors.text ?? []) as string[]} /> ) : ( - <> - - {text && ( - <> - - - - - - )} - + )} ); @@ -290,6 +258,74 @@ const SlackParamsFields: React.FunctionComponent< // eslint-disable-next-line import/no-default-export export { SlackParamsFields as default }; +type TextMessageProps = Pick & { + errors: string[]; + value?: string; + onChange: (value: string) => void; +}; + +const TextMessage = ({ index, onChange, messageVariables, errors, value }: TextMessageProps) => { + return ( + { + onChange(newValue); + }} + messageVariables={messageVariables} + paramsProperty="webApiText" + inputTargetValue={value} + label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', { + defaultMessage: 'Message', + })} + errors={errors} + /> + ); +}; + +type BlockKitMessageProps = Pick & { + errors: string[]; + textValue?: string; + onTextChange: (value: string) => void; +}; + +const BlockKitMessage = ({ + onTextChange, + messageVariables, + errors, + textValue, +}: BlockKitMessageProps) => { + return ( + <> + + {textValue && ( + <> + + + + + + )} + + ); +}; + const getSelectedChannel = ({ channels, channelIds, @@ -321,3 +357,16 @@ const formatChannel = (channel: string): string => { return `#${channel}`; }; + +const getSubAction = (messageType: string) => + messageType === 'text' ? 'postMessage' : 'postBlockkit'; + +const shouldUseDefaultValue = ({ + useDefaultMessage, + defaultMessage, + text, +}: { + useDefaultMessage?: boolean; + defaultMessage?: string; + text?: string; +}) => (Boolean(useDefaultMessage) || Boolean(defaultMessage)) && !Boolean(text); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx index 88006ce00c80c..475c0c101818f 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx @@ -78,6 +78,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { isLoading: isUpdatingConnector, updateConnector } = useUpdateConnector(); const { isLoading: isExecutingConnector, executeConnector } = useExecuteConnector(); const [showFormErrors, setShowFormErrors] = useState(false); + const actionTypeModel: ActionTypeModel | null = actionTypeRegistry.get(connector.actionTypeId); const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] = useState(null); @@ -97,7 +98,8 @@ const EditConnectorFlyoutComponent: React.FC = ({ const [testExecutionActionParams, setTestExecutionActionParams] = useState< Record - >({}); + >(actionTypeModel.defaultActionParams ?? {}); + const [testExecutionResult, setTestExecutionResult] = useState | undefined>>(none); @@ -119,7 +121,6 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { preSubmitValidator, submit, isValid: isFormValid, isSubmitting } = formState; const hasErrors = isFormValid === false; const isSaving = isUpdatingConnector || isSubmitting || isExecutingConnector; - const actionTypeModel: ActionTypeModel | null = actionTypeRegistry.get(connector.actionTypeId); const showButtons = canSave && actionTypeModel && !connector.isPreconfigured; const disabled = !isFormModified || hasErrors || isSaving; From 86157d1d9a6e160b56beee0b5c1a3a37908deb11 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 15 Dec 2025 17:22:43 +0200 Subject: [PATCH 06/12] Fix tests --- .../slack_api/slack_params.tsx | 2 +- .../components/simple_connector_form.test.tsx | 42 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index 48839697b1686..f405415aaac5c 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -32,7 +32,7 @@ type ParamsProps = ActionParamsProps; const SlackParamsFields: React.FunctionComponent = (props) => { const { editAction, index, useDefaultMessage, defaultMessage } = props; const { subActionParams } = props.actionParams; - const { channels, text, channelIds, channelNames } = subActionParams ?? {}; + const { channels = [], channelIds = [], channelNames = [], text } = subActionParams ?? {}; const connectorId = props.actionConnector?.id ?? ''; const key = `${connectorId}:${index}`; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.test.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.test.tsx index 700293edecc65..8a4e6e70fc558 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/components/simple_connector_form.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import type { RenderResult } from '@testing-library/react'; -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { FormTestProvider } from './test_utils'; import type { ConfigFieldSchema, SecretsFieldSchema } from './simple_connector_form'; import { SimpleConnectorForm } from './simple_connector_form'; @@ -120,9 +120,7 @@ describe('SimpleConnectorForm', () => { await fillForm(res); - await act(async () => { - await userEvent.click(res.getByTestId('form-test-provide-submit')); - }); + await userEvent.click(res.getByTestId('form-test-provide-submit')); expect(onSubmit).toHaveBeenCalledWith({ data: { @@ -162,6 +160,7 @@ describe('SimpleConnectorForm', () => { await fillForm(res); await userEvent.clear(res.getByTestId(field)); + if (value !== '') { await userEvent.type(res.getByTestId(field), value, { delay: 10, @@ -172,5 +171,40 @@ describe('SimpleConnectorForm', () => { expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); }); + + it('supports custom validators', async () => { + const configSchemaWitCustomValidatos = [ + { + id: 'test', + label: 'Test', + type: 'TEXT' as const, + // so only the custom validator will run + isRequired: false, + validations: [ + { + validator: () => { + return { message: 'Invalid' }; + }, + }, + ], + }, + ]; + + const res = render( + + + + ); + + await userEvent.click(res.getByTestId('form-test-provide-submit')); + + expect(await screen.findByText('Invalid')); + expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); + }); }); }); From 8af968e97aad59e6b0ca71e03de986c47026c6e9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 15 Dec 2025 17:26:23 +0200 Subject: [PATCH 07/12] Fix i18n --- .../plugins/private/translations/translations/de-DE.json | 2 -- .../plugins/private/translations/translations/fr-FR.json | 2 -- .../plugins/private/translations/translations/ja-JP.json | 2 -- .../plugins/private/translations/translations/zh-CN.json | 2 -- 4 files changed, 8 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 374e88b4bfdfe..5034bb54620fb 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -41877,9 +41877,7 @@ "xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage": "Fehler beim Posten einer Slack-Nachricht – versuchen Sie es erneut um {retryString}", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "Fehler beim Posten einer Slack-Nachricht, bitte später erneut versuchen", "xpack.stackConnectors.slack.params.blockkitLabel": "Block-Kit", - "xpack.stackConnectors.slack.params.channelIdComboBoxLabel": "Kanal-ID", "xpack.stackConnectors.slack.params.channelsComboBoxLabel": "Kanal", - "xpack.stackConnectors.slack.params.componentError.validChannelsRequestFailed": "{validChannelId} ist kein gültiger Slack-Kanal.", "xpack.stackConnectors.slack.params.textLabel": "Text", "xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage": "unerwartete HTTP-Reaktion von Slack: {httpStatus} {httpStatusText}", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "unerwartete Null-Reaktion von Slack", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index e318474acdd86..f04ce4d794b06 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -42282,9 +42282,7 @@ "xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage": "erreur lors de la publication d'un message slack, réessayer à cette date/heure : {retryString}", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement", "xpack.stackConnectors.slack.params.blockkitLabel": "Kit de blocs", - "xpack.stackConnectors.slack.params.channelIdComboBoxLabel": "ID canal", "xpack.stackConnectors.slack.params.channelsComboBoxLabel": "Canal", - "xpack.stackConnectors.slack.params.componentError.validChannelsRequestFailed": "{validChannelId} n'est pas un canal Slack valide", "xpack.stackConnectors.slack.params.textLabel": "Texte", "xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage": "réponse http inattendue de Slack : {httpStatus} {httpStatusText}", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 049ca5b6a7712..cc5ccae5baacb 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -42323,9 +42323,7 @@ "xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage": "slack メッセージの投稿エラー、 {retryString} で再試行", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行", "xpack.stackConnectors.slack.params.blockkitLabel": "Block Kit", - "xpack.stackConnectors.slack.params.channelIdComboBoxLabel": "チャンネルID", "xpack.stackConnectors.slack.params.channelsComboBoxLabel": "チャンネル", - "xpack.stackConnectors.slack.params.componentError.validChannelsRequestFailed": "{validChannelId}は有効なSlackチャンネルではありません。", "xpack.stackConnectors.slack.params.textLabel": "Text", "xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage": "slack からの予期せぬ http 応答:{httpStatus} {httpStatusText}", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "Slack から予期せぬ null 応答", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index be92f27293785..02e3e836c775a 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -42304,9 +42304,7 @@ "xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage": "发布 Slack 消息时出错,在 {retryString} 重试", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试", "xpack.stackConnectors.slack.params.blockkitLabel": "Block Kit", - "xpack.stackConnectors.slack.params.channelIdComboBoxLabel": "频道 ID", "xpack.stackConnectors.slack.params.channelsComboBoxLabel": "频道", - "xpack.stackConnectors.slack.params.componentError.validChannelsRequestFailed": "{validChannelId} 不是有效的 Slack 频道", "xpack.stackConnectors.slack.params.textLabel": "文本", "xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage": "来自 slack 的非预期 http 响应:{httpStatus} {httpStatusText}", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "来自 slack 的异常空响应", From a2f937cbbd3be6f297754d46b5388283c683d7a3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 16 Dec 2025 15:28:09 +0200 Subject: [PATCH 08/12] Fix bug with validation --- .../slack_api/slack_api.test.tsx | 27 ++++++++++++---- .../connector_types/slack_api/slack_api.tsx | 31 +++++++------------ 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx index 27840fc315c9f..9eaed9c470a9c 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx @@ -280,10 +280,15 @@ describe('Slack action params validation', () => { }); }); - it('should not fail when channelNames is set', async () => { + it.each([ + ['channelIds', undefined], + ['channels', undefined], + ['channelIds', []], + ['channels', []], + ])('should not fail when channelNames is set and %s is %s', async (key, value) => { const actionParams = { subAction: 'postMessage', - subActionParams: { text: 'some text', channelNames: ['#test'] }, + subActionParams: { text: 'some text', channelNames: ['#test'], [key]: value }, }; expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ @@ -294,10 +299,15 @@ describe('Slack action params validation', () => { }); }); - it('should not fail when channelIds is set', async () => { + it.each([ + ['channelNames', undefined], + ['channels', undefined], + ['channelNames', []], + ['channels', []], + ])('should not fail when channelIds is set and %s is %s', async (key, value) => { const actionParams = { subAction: 'postMessage', - subActionParams: { text: 'some text', channelIds: ['channel-id'] }, + subActionParams: { text: 'some text', channelIds: ['channel-id'], [key]: value }, }; expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ @@ -308,10 +318,15 @@ describe('Slack action params validation', () => { }); }); - it('should not fail when channels is set', async () => { + it.each([ + ['channelIds', undefined], + ['channelNames', undefined], + ['channelIds', []], + ['channelNames', []], + ])('should not fail when channels is set and %s is %s', async (key, value) => { const actionParams = { subAction: 'postMessage', - subActionParams: { text: 'some text', channels: ['my-channel'] }, + subActionParams: { text: 'some text', channels: ['my-channel'], [key]: value }, }; expect(await connectorTypeModel.validateParams(actionParams, null)).toEqual({ diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 73a6d82ad9ff7..72cd9a6184dd4 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -36,34 +36,21 @@ import { deserializer } from './form_deserializer'; const DEFAULT_PARAMS = { subAction: 'postMessage' as const, subActionParams: { text: undefined } }; const getChannelErrors = (channels?: string[], channelIds?: string[], channelNames?: string[]) => { - const isDefinedAndEmptyArray = (arr?: string[]) => { - if (arr && arr.length === 0) { + const isUndefinedOrEmptyArray = (arr?: string[]) => { + if (!arr || arr.length === 0) { return true; } return false; }; - const allChannels = [channels, channelIds, channelNames]; - const allUndefined = allChannels.every((channelVariable) => !Boolean(channelVariable)); + const isChannelNamesEmpty = isUndefinedOrEmptyArray(channelNames); + const isChannelIdsEmpty = isUndefinedOrEmptyArray(channelIds); + const isChannelsEmpty = isUndefinedOrEmptyArray(channels); - const isChannelNamesEmpty = isDefinedAndEmptyArray(channelNames); - const isChannelIdsEmpty = isDefinedAndEmptyArray(channelIds); - const isChannelsEmpty = isDefinedAndEmptyArray(channels); + const allEmpty = isChannelNamesEmpty && isChannelIdsEmpty && isChannelsEmpty; - if (allUndefined) { - return [CHANNEL_REQUIRED]; - } - - if (isChannelNamesEmpty) { - return [CHANNEL_REQUIRED]; - } - - if (isChannelNamesEmpty && isChannelIdsEmpty) { - return [CHANNEL_REQUIRED]; - } - - if (isChannelIdsEmpty && isChannelsEmpty) { + if (allEmpty) { return [CHANNEL_REQUIRED]; } @@ -75,6 +62,10 @@ const getChannelErrors = (channels?: string[], channelIds?: string[], channelNam return [CHANNEL_NAME_ERROR]; } + if (!allEmpty) { + return []; + } + return []; }; From a198fe3935894c2868161a0d52348b997949e8c1 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 16 Dec 2025 16:13:55 +0200 Subject: [PATCH 09/12] Add helptext --- .../translations/translations/de-DE.json | 3 --- .../translations/translations/fr-FR.json | 3 --- .../translations/translations/ja-JP.json | 3 --- .../translations/translations/zh-CN.json | 3 --- .../slack_api/slack_params.tsx | 2 +- .../connector_types/slack_api/translations.ts | 25 +------------------ 6 files changed, 2 insertions(+), 37 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 18bb8fc78a22c..466bd6584950e 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -41589,10 +41589,7 @@ "xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "Nachricht ist erforderlich.", "xpack.stackConnectors.components.slack_api.error.slackBlockkitBlockRequired": "JSON muss das Feld „blocks“ enthalten.", "xpack.stackConnectors.components.slack_api.error.slackBlockkitJsonRequired": "Das Block-Kit muss ein gültiges JSON sein.", - "xpack.stackConnectors.components.slack_api.errorInvalidChannelsText": "Die Kanal-ID \"{channels}\" kann nicht validiert werden. Bitte überprüfen Sie die Gültigkeit Ihres Tokens und/oder der Kanal-ID.", - "xpack.stackConnectors.components.slack_api.errorValidChannelsText": "Kanäle können nicht validiert werden, bitte überprüfen Sie die Gültigkeit Ihres Tokens oder Ihres Kanals", "xpack.stackConnectors.components.slack_api.selectMessageText": "Senden Sie Nachrichten an Slack-Kanäle.", - "xpack.stackConnectors.components.slack_api.successFetchChannelsText": "Alle Kanäle abrufen", "xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "API-Token", "xpack.stackConnectors.components.slack_api.webApi": "Web-API", "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Nachricht ist erforderlich.", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 451f1a68911a5..7910e25c815a1 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -41996,10 +41996,7 @@ "xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "Le message est requis.", "xpack.stackConnectors.components.slack_api.error.slackBlockkitBlockRequired": "L'objet JSON doit contenir des \"blocs\" de champs.", "xpack.stackConnectors.components.slack_api.error.slackBlockkitJsonRequired": "Le kit de blocs doit être un objet JSON valide.", - "xpack.stackConnectors.components.slack_api.errorInvalidChannelsText": "Impossible de valider l'ID \"{channels}\" du canal. Nous vous invitons à vérifier la validité de votre token ou l'ID du canal", - "xpack.stackConnectors.components.slack_api.errorValidChannelsText": "Impossible de valider les canaux, veuillez vérifier la validité de votre token ou de votre canal", "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", "xpack.stackConnectors.components.slack_api.webApi": "API web", "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Le message est requis.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 340e8b74b0da1..844c15e892755 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -42037,10 +42037,7 @@ "xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "メッセージが必要です。", "xpack.stackConnectors.components.slack_api.error.slackBlockkitBlockRequired": "JSONには\"blocks\"フィールドを含める必要があります。", "xpack.stackConnectors.components.slack_api.error.slackBlockkitJsonRequired": "Block kitは有効なJSONでなければなりません。", - "xpack.stackConnectors.components.slack_api.errorInvalidChannelsText": "チャンネルID \"{channels}\"を検証できません。トークンまたはチャンネルIDの有効性を確認してください。", - "xpack.stackConnectors.components.slack_api.errorValidChannelsText": "有効なチャンネルではありません。トークンまたはチャンネルの有効期限を確認してください", "xpack.stackConnectors.components.slack_api.selectMessageText": "メッセージをSlackチャンネルに送信します。", - "xpack.stackConnectors.components.slack_api.successFetchChannelsText": "すべてのチャンネルを取得", "xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "APIトークン", "xpack.stackConnectors.components.slack_api.webApi": "Web API", "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "メッセージが必要です。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 99de130d62f4e..c3a59ea75c4e6 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -42018,10 +42018,7 @@ "xpack.stackConnectors.components.slack_api.error.requiredSlackMessageText": "“消息”必填。", "xpack.stackConnectors.components.slack_api.error.slackBlockkitBlockRequired": "JSON 必须包含字段“块”。", "xpack.stackConnectors.components.slack_api.error.slackBlockkitJsonRequired": "Block Kit 必须为有效 JSON。", - "xpack.stackConnectors.components.slack_api.errorInvalidChannelsText": "无法验证频道 ID“{channels}”,请检查您的令牌和/或频道 ID 的有效性", - "xpack.stackConnectors.components.slack_api.errorValidChannelsText": "无法使频道生效,请检查您的令牌或频道的有效性", "xpack.stackConnectors.components.slack_api.selectMessageText": "向 Slack 频道发送消息。", - "xpack.stackConnectors.components.slack_api.successFetchChannelsText": "提取所有频道", "xpack.stackConnectors.components.slack_api.tokenTextFieldLabel": "API 令牌", "xpack.stackConnectors.components.slack_api.webApi": "Web API", "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "“消息”必填。", diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx index f405415aaac5c..9d13fa791ac15 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -200,7 +200,7 @@ const SlackParamsFieldsComponent: React.FunctionComponent = ({ <> - 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(', '), - }, - }); export const JSON_REQUIRED = i18n.translate( 'xpack.stackConnectors.components.slack_api.error.slackBlockkitJsonRequired', From ee6838ef282e926dce601f2f6664f7cb4a750527 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 17 Dec 2025 10:04:48 +0200 Subject: [PATCH 10/12] Fix integration test --- .../apps/triggers_actions_ui/connectors/opsgenie.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts b/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts index 4778f42e5f1e6..49426c211035a 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts @@ -222,7 +222,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { description: 'a description', alias: 'an alias', priority: 'P5', - tags: ['a tag'], + tags: ['{{rule.tags}}', 'a tag'], }); }); From 17092803ffc23808d89f5812d16d5a2e15d112e6 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 17 Dec 2025 11:57:19 +0200 Subject: [PATCH 11/12] Fix issues with the test form --- .../edit_connector_flyout/index.tsx | 19 +++++++++++++------ .../test_connector_form.tsx | 11 +++-------- .../connectors/opsgenie.ts | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx index 475c0c101818f..e6cca7f265f8c 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx @@ -78,7 +78,6 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { isLoading: isUpdatingConnector, updateConnector } = useUpdateConnector(); const { isLoading: isExecutingConnector, executeConnector } = useExecuteConnector(); const [showFormErrors, setShowFormErrors] = useState(false); - const actionTypeModel: ActionTypeModel | null = actionTypeRegistry.get(connector.actionTypeId); const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] = useState(null); @@ -98,7 +97,13 @@ const EditConnectorFlyoutComponent: React.FC = ({ const [testExecutionActionParams, setTestExecutionActionParams] = useState< Record - >(actionTypeModel.defaultActionParams ?? {}); + >({}); + + const onEditAction = useCallback( + (field: string, value: unknown) => + setTestExecutionActionParams((oldParams) => ({ ...oldParams, [field]: value })), + [] + ); const [testExecutionResult, setTestExecutionResult] = useState | undefined>>(none); @@ -121,6 +126,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { preSubmitValidator, submit, isValid: isFormValid, isSubmitting } = formState; const hasErrors = isFormValid === false; const isSaving = isUpdatingConnector || isSubmitting || isExecutingConnector; + const actionTypeModel: ActionTypeModel | null = actionTypeRegistry.get(connector.actionTypeId); const showButtons = canSave && actionTypeModel && !connector.isPreconfigured; const disabled = !isFormModified || hasErrors || isSaving; @@ -295,7 +301,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ connector={connector} executeEnabled={!isFormModified} actionParams={testExecutionActionParams} - setActionParams={setTestExecutionActionParams} + onEditAction={onEditAction} onExecutionAction={onExecutionAction} isExecutingAction={isExecutingConnector} executionResult={testExecutionResult} @@ -304,12 +310,13 @@ const EditConnectorFlyoutComponent: React.FC = ({ ); }, [ connector, - actionTypeRegistry, - isExecutingConnector, isFormModified, testExecutionActionParams, - testExecutionResult, + onEditAction, onExecutionAction, + isExecutingConnector, + testExecutionResult, + actionTypeRegistry, ]); const renderConnectorRulesList = useCallback(() => { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 75f2b3ff70d3b..3af13a26d2fdd 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -33,7 +33,7 @@ export interface TestConnectorFormProps { connector: ActionConnector; executeEnabled: boolean; isExecutingAction: boolean; - setActionParams: (params: Record) => void; + onEditAction: (field: string, value: unknown) => void; actionParams: Record; onExecutionAction: () => Promise; executionResult: Option | undefined>; @@ -45,7 +45,7 @@ export const TestConnectorForm = ({ executeEnabled, executionResult, actionParams, - setActionParams, + onEditAction, onExecutionAction, isExecutingAction, actionTypeRegistry, @@ -91,12 +91,7 @@ export const TestConnectorForm = ({ actionParams={actionParams} index={0} errors={actionErrors} - editAction={(field, value) => - setActionParams({ - ...actionParams, - [field]: value, - }) - } + editAction={onEditAction} messageVariables={[]} actionConnector={connector} executionMode={ActionConnectorMode.Test} diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts b/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts index 49426c211035a..4778f42e5f1e6 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts @@ -222,7 +222,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { description: 'a description', alias: 'an alias', priority: 'P5', - tags: ['{{rule.tags}}', 'a tag'], + tags: ['a tag'], }); }); From c21642ecfb5dda3fb8bce1fdefba9f6280a40f0b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 17 Dec 2025 12:28:33 +0200 Subject: [PATCH 12/12] Fix types --- .../test_connector_form.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index ea580616225c2..1f338751e4ac4 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -104,7 +104,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={none} @@ -148,7 +148,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={none} @@ -176,7 +176,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={some({ @@ -203,7 +203,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={some({ @@ -231,7 +231,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={some({ @@ -269,7 +269,7 @@ describe('test_connector_form', () => { connector={connector} executeEnabled={true} actionParams={{}} - setActionParams={() => {}} + onEditAction={() => {}} isExecutingAction={false} onExecutionAction={async () => {}} executionResult={some(undefined)}