Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export function getConnectorType(): ConnectorTypeModel<
actionTypeTitle: i18n.translate('xpack.stackConnectors.components.http.connectorTypeTitle', {
defaultMessage: 'HTTP',
}),
getHideInUi: () => true, // hidden from the stack connectors UI, will still be available for workflows UI
actionConnectorFields: lazy(() => import('./http_connectors')),
actionParamsFields: lazy(() => import('./http_params')),
validateParams: async (actionParams: ActionParamsType) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,44 @@ describe('connector_add_flyout', () => {
expect(screen.queryByTestId('action-type-2-card')).not.toBeInTheDocument();
});

it('Filters connectors based on selected feature ids', async () => {
loadActionTypes.mockResolvedValue([
{
id: actionType1.id,
enabled: true,
name: 'Jira',
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
},
{
id: actionType2.id,
enabled: true,
name: 'Webhook',
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['cases'],
},
]);

actionTypeRegistry.get.mockImplementation((id) =>
id === actionType1.id ? actionType1 : actionType2
);

appMockRenderer.render(
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypeRegistry={actionTypeRegistry}
selectedFeatureIds={['cases']}
/>
);

expect(await screen.findByTestId('action-type-2-card')).toBeInTheDocument();
expect(screen.queryByTestId('action-type-1-card')).not.toBeInTheDocument();
});

it('Filters connectors based on selectMessage search', async () => {
loadActionTypes.mockResolvedValue([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface Props {
setAllActionTypes?: (actionsType: ActionTypeIndex) => void;
actionTypeRegistry: ActionTypeRegistryContract;
searchValue?: string;
selectedFeatureIds?: string[];
}

interface RegisteredActionType {
Expand All @@ -56,17 +57,36 @@ interface RegisteredActionType {
isDeprecated: boolean;
}

const filterActionTypes = (actionTypes: RegisteredActionType[], searchValue: string) => {
if (isEmpty(searchValue)) {
const filterActionTypes = (
actionTypes: RegisteredActionType[],
searchValue: string,
selectedFeatureIds: string[]
) => {
const hasSearch = !isEmpty(searchValue);
const hasFeatureFilter = selectedFeatureIds.length > 0;

if (!hasSearch && !hasFeatureFilter) {
return actionTypes;
}

const searchValueLowerCase = searchValue.toLowerCase();

return actionTypes.filter((actionType) => {
if (hasFeatureFilter) {
const supported = actionType.actionType?.supportedFeatureIds ?? [];
if (!supported.some((id) => selectedFeatureIds.includes(id))) {
return false;
}
}

if (!hasSearch) {
return true;
}

const searchTargets = [actionType.name, actionType.selectMessage, actionType.actionType?.name]
.filter(Boolean)
.map((text) => text.toLowerCase());

const searchValueLowerCase = searchValue.toLowerCase();

return searchTargets.some((searchTarget) => searchTarget.includes(searchValueLowerCase));
});
};
Expand All @@ -87,6 +107,7 @@ export const ActionTypeMenu = ({
setAllActionTypes,
actionTypeRegistry,
searchValue = '',
selectedFeatureIds = [],
}: Props) => {
const {
http,
Expand Down Expand Up @@ -180,8 +201,8 @@ export const ActionTypeMenu = ({
});

const filteredConnectors = useMemo(
() => filterActionTypes(registeredActionTypes, searchValue),
[registeredActionTypes, searchValue]
() => filterActionTypes(registeredActionTypes, searchValue, selectedFeatureIds),
[registeredActionTypes, searchValue, selectedFeatureIds]
);

const cardNodes = filteredConnectors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,71 @@ import React from 'react';
import type { AppMockRenderer } from '../../test_utils';
import { createAppMockRenderer } from '../../test_utils';
import { CreateConnectorFilter } from './create_connector_filter';
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('CreateConnectorFilter', () => {
let appMockRenderer: AppMockRenderer;
const mockOnSearchValueChange = jest.fn();
const mockOnSelectedFeatureIdsChange = jest.fn();
const featureOptions = [
{ value: 'alerting', label: 'Alerting' },
{ value: 'cases', label: 'Cases' },
];

const defaultProps = {
searchValue: '',
onSearchValueChange: mockOnSearchValueChange,
selectedFeatureIds: [],
onSelectedFeatureIdsChange: mockOnSelectedFeatureIdsChange,
featureOptions,
};

beforeEach(() => {
jest.clearAllMocks();
appMockRenderer = createAppMockRenderer();
});

it('renders', async () => {
appMockRenderer = createAppMockRenderer();
appMockRenderer.render(
<CreateConnectorFilter searchValue="" onSearchValueChange={mockOnSearchValueChange} />
);
appMockRenderer.render(<CreateConnectorFilter {...defaultProps} />);

expect(screen.getByTestId('createConnectorsModalSearch')).toBeInTheDocument();
expect(screen.getByTestId('createConnectorsModalFeatureFilter')).toBeInTheDocument();
});

it('mockOnSearchValueChange is called correctly', async () => {
appMockRenderer = createAppMockRenderer();
appMockRenderer.render(
<CreateConnectorFilter searchValue="" onSearchValueChange={mockOnSearchValueChange} />
);
appMockRenderer.render(<CreateConnectorFilter {...defaultProps} />);

await userEvent.click(await screen.findByTestId('createConnectorsModalSearch'));
await userEvent.paste('Test');

expect(mockOnSearchValueChange).toHaveBeenCalledWith('Test');
});

it('calls onSelectedFeatureIdsChange when a feature option is selected', async () => {
appMockRenderer.render(<CreateConnectorFilter {...defaultProps} />);

const comboBox = await screen.findByTestId('createConnectorsModalFeatureFilter');
await userEvent.click(within(comboBox).getByRole('combobox'));

await userEvent.click(await screen.findByText('Alerting'));

expect(mockOnSelectedFeatureIdsChange).toHaveBeenCalledWith(['alerting']);
});

it('renders selected feature ids as chips', async () => {
appMockRenderer.render(
<CreateConnectorFilter {...defaultProps} selectedFeatureIds={['cases']} />
);

const comboBox = await screen.findByTestId('createConnectorsModalFeatureFilter');
expect(within(comboBox).getByTitle('Cases')).toBeInTheDocument();
});

it('disables the feature filter when featureFilterDisabled is true', async () => {
appMockRenderer.render(<CreateConnectorFilter {...defaultProps} featureFilterDisabled />);

const comboBox = await screen.findByTestId('createConnectorsModalFeatureFilter');
expect(within(comboBox).getByRole('combobox')).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,53 @@
*/

import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch } from '@elastic/eui';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiComboBox } from '@elastic/eui';

import { i18n } from '@kbn/i18n';

export interface FeatureFilterOption {
value: string;
label: string;
}

export interface CreateConnectorFilterProps {
searchValue: string;
onSearchValueChange: (value: string) => void;
selectedFeatureIds: string[];
onSelectedFeatureIdsChange: (ids: string[]) => void;
featureOptions: FeatureFilterOption[];
featureFilterDisabled?: boolean;
}

export const CreateConnectorFilter: React.FC<CreateConnectorFilterProps> = ({
searchValue,
onSearchValueChange,
selectedFeatureIds,
onSelectedFeatureIdsChange,
featureOptions,
featureFilterDisabled = false,
}) => {
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onSearchValueChange(newValue);
};

const comboOptions: Array<EuiComboBoxOptionOption<string>> = featureOptions.map((option) => ({
label: option.label,
value: option.value,
}));

const selectedComboOptions: Array<EuiComboBoxOptionOption<string>> = selectedFeatureIds
.map((id) => comboOptions.find((option) => option.value === id))
.filter((option): option is EuiComboBoxOptionOption<string> => option !== undefined);

const handleFeatureChange = (options: Array<EuiComboBoxOptionOption<string>>) => {
onSelectedFeatureIdsChange(
options.map((option) => option.value).filter((value): value is string => value !== undefined)
);
};

return (
<EuiFlexGroup gutterSize="s" wrap={false} responsive={false}>
<EuiFlexItem grow={3}>
Expand All @@ -46,6 +75,28 @@ export const CreateConnectorFilter: React.FC<CreateConnectorFilterProps> = ({
value={searchValue}
/>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiComboBox
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorAdd.featureFilter.ariaLabel',
{
defaultMessage: 'Filter connector types by supported feature',
}
)}
placeholder={i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorAdd.featureFilter.placeholder',
{
defaultMessage: 'Filter by feature',
}
)}
data-test-subj="createConnectorsModalFeatureFilter"
options={comboOptions}
selectedOptions={selectedComboOptions}
onChange={handleFeatureChange}
isDisabled={featureFilterDisabled}
fullWidth
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React, { lazy } from 'react';

import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import userEvent from '@testing-library/user-event';
import { waitFor, screen } from '@testing-library/react';
import { waitFor, screen, within } from '@testing-library/react';
import CreateConnectorFlyout from '.';
import type { AppMockRenderer } from '../../test_utils';
import { createAppMockRenderer } from '../../test_utils';
Expand Down Expand Up @@ -98,6 +98,39 @@ describe('CreateConnectorFlyout', () => {
expect(await screen.findByTestId(`${actionTypeModel.id}-card`)).toBeInTheDocument();
});

it('renders the feature filter with options derived from loaded action types', async () => {
appMockRenderer.render(
<CreateConnectorFlyout
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onConnectorCreated={onConnectorCreated}
onTestConnector={onTestConnector}
/>
);

const comboBox = await screen.findByTestId('createConnectorsModalFeatureFilter');
await userEvent.click(within(comboBox).getByRole('combobox'));

expect(await screen.findByText('Alerting')).toBeInTheDocument();
expect(screen.getByText('Security Solution')).toBeInTheDocument();
});

it('pre-selects and disables the feature filter when featureId prop is provided', async () => {
appMockRenderer.render(
<CreateConnectorFlyout
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onConnectorCreated={onConnectorCreated}
onTestConnector={onTestConnector}
featureId="alerting"
/>
);

const comboBox = await screen.findByTestId('createConnectorsModalFeatureFilter');
expect(within(comboBox).getByTitle('Alerting')).toBeInTheDocument();
expect(within(comboBox).getByRole('combobox')).toBeDisabled();
});

it('shows the correct buttons without an action type selected', async () => {
appMockRenderer.render(
<CreateConnectorFlyout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { IconType } from '@elastic/eui';
import { ACTION_TYPE_SOURCES } from '@kbn/actions-types';

import { i18n } from '@kbn/i18n';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { getConnectorCompatibility, getConnectorFeatureName } from '@kbn/actions-plugin/common';
import type { ConnectorFormSchema } from '@kbn/alerts-ui-shared';
import { useActionTypeModel } from '@kbn/alerts-ui-shared/src/common/hooks/use_action_type_model';
import { isLLMConnectorTypeId } from '@kbn/response-ops-rule-form/src/constants';
Expand Down Expand Up @@ -83,6 +83,9 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
const canSave = hasSaveActionsCapability(capabilities);
const [showFormErrors, setShowFormErrors] = useState<boolean>(false);
const [searchValue, setSearchValue] = useState<string>('');
const [selectedFeatureIds, setSelectedFeatureIds] = useState<string[]>(
featureId ? [featureId] : []
);

const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] =
useState<ReactNode>(null);
Expand Down Expand Up @@ -266,6 +269,25 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
setSearchValue(newValue);
}, []);

const handleSelectedFeatureIdsChange = useCallback((ids: string[]) => {
setSelectedFeatureIds(ids);
}, []);

const featureFilterOptions = useMemo(() => {
if (!allActionTypes) {
return [];
}
const uniqueFeatureIds = new Set<string>();
for (const actionTypeItem of Object.values(allActionTypes)) {
for (const supportedFeatureId of actionTypeItem.supportedFeatureIds ?? []) {
uniqueFeatureIds.add(supportedFeatureId);
}
}
return Array.from(uniqueFeatureIds)
.map((id) => ({ value: id, label: getConnectorFeatureName(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [allActionTypes]);

useEffect(() => {
isMounted.current = true;

Expand Down Expand Up @@ -314,6 +336,10 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
<CreateConnectorFilter
searchValue={searchValue}
onSearchValueChange={handleSearchValueChange}
selectedFeatureIds={selectedFeatureIds}
onSelectedFeatureIdsChange={handleSelectedFeatureIdsChange}
featureOptions={featureFilterOptions}
featureFilterDisabled={Boolean(featureId)}
/>
<EuiSpacer size="m" />
</>
Expand Down Expand Up @@ -466,6 +492,7 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
setAllActionTypes={setAllActionTypes}
actionTypeRegistry={actionTypeRegistry}
searchValue={searchValue}
selectedFeatureIds={selectedFeatureIds}
/>
)}
</EuiFlyoutBody>
Expand Down
Loading