[ResponseOps][Connectors] Add connector filter in flyout (#211874)

Closes https://github.com/elastic/kibana/issues/208001

## Summary

- Added filtering option in the `create connector` flyout: 

-  Search field: 
    - search connectors by name (or description)
    - cards dynamically update based on the input
    - includes a clear button to reset the search 


Demo: 


https://github.com/user-attachments/assets/6d38a916-ad05-41dd-867e-c37260913067

---------

Co-authored-by: Christos Nasikas <xristosnasikas@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Georgiana-Andreea Onoleață 2025-03-20 18:06:00 +02:00 committed by GitHub
parent da055061ef
commit 2b96a82d4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 282 additions and 6 deletions

View file

@ -6,13 +6,14 @@
*/
import * as React from 'react';
import { act } from '@testing-library/react';
import { act, screen } from '@testing-library/react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { coreMock } from '@kbn/core/public/mocks';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ActionTypeMenu } from './action_type_menu';
import { GenericValidationResult } from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
import { AppMockRenderer, createAppMockRenderer } from '../test_utils';
jest.mock('../../../common/lib/kibana');
jest.mock('../../lib/action_connector_api', () => ({
@ -167,6 +168,115 @@ describe('connector_add_flyout', () => {
});
});
describe('filtering', () => {
let appMockRenderer: AppMockRenderer;
const onActionTypeChange = jest.fn();
const actionType1 = actionTypeRegistryMock.createMockActionTypeModel({
id: 'action-type-1',
iconClass: 'test',
selectMessage: 'Select Test1',
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});
const actionType2 = actionTypeRegistryMock.createMockActionTypeModel({
id: 'action-type-2',
iconClass: 'test',
selectMessage: 'Select Test2',
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});
beforeEach(() => {
jest.clearAllMocks();
});
it('Filters connectors based on name search', async () => {
appMockRenderer = createAppMockRenderer();
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}
searchValue="Jira"
/>
);
expect(await screen.findByTestId('action-type-1-card')).toBeInTheDocument();
expect(screen.queryByTestId('action-type-2-card')).not.toBeInTheDocument();
});
it('Filters connectors based on selectMessage search', async () => {
appMockRenderer = createAppMockRenderer();
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}
searchValue="Select Test2"
/>
);
expect(await screen.findByTestId('action-type-2-card')).toBeInTheDocument();
expect(screen.queryByTestId('action-type-1-card')).not.toBeInTheDocument();
});
});
describe('beta badge', () => {
it(`does not render beta badge when isExperimental=undefined`, async () => {
const onActionTypeChange = jest.fn();

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui';
import React, { useEffect, useState, useMemo } from 'react';
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer, IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isEmpty } from 'lodash';
import { checkActionTypeEnabled } from '@kbn/alerts-ui-shared/src/check_action_type_enabled';
import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations';
import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types';
@ -24,14 +25,39 @@ interface Props {
setHasActionsUpgradeableByTrial?: (value: boolean) => void;
setAllActionTypes?: (actionsType: ActionTypeIndex) => void;
actionTypeRegistry: ActionTypeRegistryContract;
searchValue?: string;
}
interface RegisteredActionType {
iconClass: IconType;
selectMessage: string;
actionType: ActionType;
name: string;
isExperimental: boolean | undefined;
}
const filterActionTypes = (actionTypes: RegisteredActionType[], searchValue: string) => {
if (isEmpty(searchValue)) {
return actionTypes;
}
return actionTypes.filter((actionType) => {
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));
});
};
export const ActionTypeMenu = ({
onActionTypeChange,
featureId,
setHasActionsUpgradeableByTrial,
setAllActionTypes,
actionTypeRegistry,
searchValue = '',
}: Props) => {
const {
http,
@ -39,6 +65,7 @@ export const ActionTypeMenu = ({
} = useKibana().services;
const [loadingActionTypes, setLoadingActionTypes] = useState<boolean>(false);
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
useEffect(() => {
(async () => {
try {
@ -95,7 +122,12 @@ export const ActionTypeMenu = ({
};
});
const cardNodes = registeredActionTypes
const filteredConnectors = useMemo(
() => filterActionTypes(registeredActionTypes, searchValue),
[registeredActionTypes, searchValue]
);
const cardNodes = filteredConnectors
.sort((a, b) => actionTypeCompare(a.actionType, b.actionType))
.map((item, index) => {
const checkEnabledResult = checkActionTypeEnabled(item.actionType);

View file

@ -0,0 +1,42 @@
/*
* 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 React from 'react';
import { AppMockRenderer, createAppMockRenderer } from '../../test_utils';
import { CreateConnectorFilter } from './create_connector_filter';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('CreateConnectorFilter', () => {
let appMockRenderer: AppMockRenderer;
const mockOnSearchValueChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
appMockRenderer = createAppMockRenderer();
appMockRenderer.render(
<CreateConnectorFilter searchValue="" onSearchValueChange={mockOnSearchValueChange} />
);
expect(screen.getByTestId('createConnectorsModalSearch')).toBeInTheDocument();
});
it('mockOnSearchValueChange is called correctly', async () => {
appMockRenderer = createAppMockRenderer();
appMockRenderer.render(
<CreateConnectorFilter searchValue="" onSearchValueChange={mockOnSearchValueChange} />
);
await userEvent.click(await screen.findByTestId('createConnectorsModalSearch'));
await userEvent.paste('Test');
expect(mockOnSearchValueChange).toHaveBeenCalledWith('Test');
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export interface CreateConnectorFilterProps {
searchValue: string;
onSearchValueChange: (value: string) => void;
}
export const CreateConnectorFilter: React.FC<CreateConnectorFilterProps> = ({
searchValue,
onSearchValueChange,
}) => {
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onSearchValueChange(newValue);
};
return (
<EuiFlexGroup gutterSize="s" wrap={false} responsive={false}>
<EuiFlexItem grow={3}>
<EuiFieldSearch
fullWidth={true}
placeholder={i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorAdd.searchConnector',
{
defaultMessage: 'Search',
}
)}
data-test-subj="createConnectorsModalSearch"
onChange={handleSearchChange}
value={searchValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -142,7 +142,6 @@ describe('CreateConnectorFlyout', () => {
onConnectorCreated={onConnectorCreated}
/>
);
await act(() => Promise.resolve());
expect(queryByTestId('create-connector-flyout-save-test-btn')).not.toBeInTheDocument();
});
@ -424,6 +423,36 @@ describe('CreateConnectorFlyout', () => {
});
});
describe('Filters', () => {
it('displays search field', async () => {
appMockRenderer.render(
<CreateConnectorFlyout
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onConnectorCreated={onConnectorCreated}
onTestConnector={onTestConnector}
/>
);
expect(await screen.findByTestId('createConnectorsModalSearch')).toBeInTheDocument();
});
it('does not show the search field after an action type is selected', async () => {
appMockRenderer.render(
<CreateConnectorFlyout
actionTypeRegistry={actionTypeRegistry}
onClose={onClose}
onConnectorCreated={onConnectorCreated}
onTestConnector={onTestConnector}
/>
);
expect(await screen.findByTestId('createConnectorsModalSearch')).toBeInTheDocument();
await userEvent.click(await screen.findByTestId(`${actionTypeModel.id}-card`));
expect(await screen.queryByTestId('createConnectorsModalSearch')).not.toBeInTheDocument();
});
});
describe('Submitting', () => {
it('creates a connector correctly', async () => {
const { getByTestId, queryByTestId } = appMockRenderer.render(

View file

@ -17,9 +17,10 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { CreateConnectorFilter } from './create_connector_filter';
import {
ActionConnector,
ActionType,
@ -63,6 +64,7 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState<boolean>(false);
const canSave = hasSaveActionsCapability(capabilities);
const [showFormErrors, setShowFormErrors] = useState<boolean>(false);
const [searchValue, setSearchValue] = useState<string>('');
const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] =
useState<ReactNode>(null);
@ -88,6 +90,7 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
const hasErrors = isFormValid === false;
const isSaving = isSavingConnector || isSubmitting;
const hasConnectorTypeSelected = actionType != null;
const actionTypeModel: ActionTypeModel | null =
actionType != null ? actionTypeRegistry.get(actionType.id) : null;
@ -198,6 +201,10 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
}
}, [validateAndCreateConnector, onClose, onConnectorCreated]);
const handleSearchValueChange = useCallback((newValue: string) => {
setSearchValue(newValue);
}, []);
useEffect(() => {
isMounted.current = true;
@ -218,6 +225,16 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
<EuiFlyoutBody
banner={!actionType && hasActionsUpgradeableByTrial ? <UpgradeLicenseCallOut /> : null}
>
{!hasConnectorTypeSelected && (
<>
<CreateConnectorFilter
searchValue={searchValue}
onSearchValueChange={handleSearchValueChange}
/>
<EuiSpacer size="m" />
</>
)}
{hasConnectorTypeSelected ? (
<>
{groupActionTypeModel && (
@ -308,6 +325,7 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
setHasActionsUpgradeableByTrial={setHasActionsUpgradeableByTrial}
setAllActionTypes={setAllActionTypes}
actionTypeRegistry={actionTypeRegistry}
searchValue={searchValue}
/>
)}
</EuiFlyoutBody>