mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
da055061ef
commit
2b96a82d4f
6 changed files with 282 additions and 6 deletions
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue