From 847e0cbe728fb6bb49f6b2f888f6c832accb4317 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 16 Aug 2023 04:13:10 -0600 Subject: [PATCH] [Security Solution] Adds Connector Selector to Assistant Title Header (#163666) ## Summary Adds a new `ConnectorSelectorInline` component that is displayed below the Assistant title header. Default:

Overflow:

Missing:

Open:

### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [X] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) --- .../impl/assistant/assistant_header/index.tsx | 6 +- .../assistant/assistant_title/index.test.tsx | 19 +- .../impl/assistant/assistant_title/index.tsx | 82 +++-- .../connector_missing_callout/index.tsx | 2 +- .../connector_selector_inline.test.tsx | 116 +++++++ .../connector_selector_inline.tsx | 286 ++++++++++++++++++ .../impl/connectorland/translations.ts | 14 + 7 files changed, 494 insertions(+), 31 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx index eb05133c70835..773b46bdb2ab2 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_header/index.tsx @@ -80,7 +80,11 @@ export const AssistantHeader: React.FC = ({ justifyContent={'spaceBetween'} > - + { it('the component renders correctly with valid props', () => { - const { getByText, container } = render(); + const { getByText, container } = render( + + + + ); expect(getByText('Test Title')).toBeInTheDocument(); expect(container.querySelector('[data-euiicon-type="globe"]')).not.toBeNull(); }); it('clicking on the popover button opens the popover with the correct link', () => { - const { getByTestId, queryByTestId } = render(, { - wrapper: TestProviders, - }); + const { getByTestId, queryByTestId } = render( + + + , + { + wrapper: TestProviders, + } + ); expect(queryByTestId('tooltipContent')).not.toBeInTheDocument(); fireEvent.click(getByTestId('tooltipIcon')); expect(getByTestId('tooltipContent')).toBeInTheDocument(); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx index 719a02aaee132..f2d77c9fb6716 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_title/index.tsx @@ -15,10 +15,14 @@ import { EuiModalHeaderTitle, EuiPopover, EuiText, + EuiTitle, } from '@elastic/eui'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; import * as i18n from '../translations'; +import type { Conversation } from '../../..'; +import { ConnectorSelectorInline } from '../../connectorland/connector_selector_inline/connector_selector_inline'; /** * Renders a header title with an icon, a tooltip button, and a popover with @@ -28,7 +32,10 @@ export const AssistantTitle: React.FC<{ title: string | JSX.Element; titleIcon: string; docLinks: Omit; -}> = ({ title, titleIcon, docLinks }) => { + selectedConversation: Conversation | undefined; +}> = ({ title, titleIcon, docLinks, selectedConversation }) => { + const selectedConnectorId = selectedConversation?.apiConfig?.connectorId; + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const url = `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/security-assistant.html`; @@ -66,32 +73,57 @@ export const AssistantTitle: React.FC<{ return ( - - + + - {title} - - - } - isOpen={isPopoverOpen} - closePopover={closePopover} - anchorPosition="upCenter" - > - -

{i18n.TOOLTIP_TITLE}

-

{content}

-
-
-
+ + + + + +

{title}

+
+
+ + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="rightUp" + > + +

{i18n.TOOLTIP_TITLE}

+ +

{content}

+
+
+
+
+
+
+ + {}} + onConnectorSelectionChange={() => {}} + selectedConnectorId={selectedConnectorId} + selectedConversation={selectedConversation} + /> + +
); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx index 671cac8ea01c6..1c56e005892c5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.tsx @@ -46,7 +46,7 @@ export const ConnectorMissingCallout: React.FC = React.memo(

{' '} ({ + loadActionTypes: jest.fn(() => { + return Promise.resolve([ + { + id: '.gen-ai', + name: 'Gen AI', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]); + }), +})); + +jest.mock('../use_load_connectors', () => ({ + useLoadConnectors: jest.fn(() => { + return { + data: [], + error: null, + isSuccess: true, + }; + }), +})); + +const mockConnectors = [ + { + id: 'connectorId', + name: 'Captain Connector', + isMissingSecrets: false, + actionTypeId: '.gen-ai', + config: { + apiProvider: 'OpenAI', + }, + }, +]; + +(useLoadConnectors as jest.Mock).mockReturnValue({ + data: mockConnectors, + error: null, + isSuccess: true, +}); + +describe('ConnectorSelectorInline', () => { + it('renders empty view if no selected conversation is provided', () => { + const { getByText } = render( + + + + ); + expect(getByText(i18n.INLINE_CONNECTOR_PLACEHOLDER)).toBeInTheDocument(); + }); + + it('renders empty view if selectedConnectorId is NOT in list of connectors', () => { + const conversation: Conversation = { + id: 'conversation_id', + messages: [], + apiConfig: {}, + }; + const { getByText } = render( + + + + ); + expect(getByText(i18n.INLINE_CONNECTOR_PLACEHOLDER)).toBeInTheDocument(); + }); + + it('renders selected connector if selected selectedConnectorId is in list of connectors', () => { + const conversation: Conversation = { + id: 'conversation_id', + messages: [], + apiConfig: {}, + }; + const { getByText } = render( + + + + ); + expect(getByText(mockConnectors[0].name)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx new file mode 100644 index 0000000000000..09b77c642ffcf --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx @@ -0,0 +1,286 @@ +/* + * 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSuperSelect, EuiText } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; + +import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; +import { + GEN_AI_CONNECTOR_ID, + OpenAiProviderType, +} from '@kbn/stack-connectors-plugin/public/common'; +import { css } from '@emotion/css/dist/emotion-css.cjs'; +import { Conversation } from '../../..'; +import { useLoadConnectors } from '../use_load_connectors'; +import * as i18n from '../translations'; +import { useLoadActionTypes } from '../use_load_action_types'; +import { useAssistantContext } from '../../assistant_context'; +import { useConversation } from '../../assistant/use_conversation'; + +export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR'; +interface Props { + isDisabled?: boolean; + onConnectorSelectionChange: (connectorId: string, provider: OpenAiProviderType) => void; + selectedConnectorId?: string; + selectedConversation?: Conversation; + onConnectorModalVisibilityChange?: (isVisible: boolean) => void; +} + +interface Config { + apiProvider: string; +} + +const inputContainerClassName = css` + height: 32px; + + .euiSuperSelect { + width: 400px; + } + + .euiSuperSelectControl { + border: none; + box-shadow: none; + background: none; + padding-left: 0; + } + + .euiFormControlLayoutIcons { + right: 14px; + top: 2px; + } +`; + +const inputDisplayClassName = css` + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +`; + +const placeholderButtonClassName = css` + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; + font-weight: normal; + padding-bottom: 5px; + padding-left: 0; + padding-top: 2px; +`; + +/** + * A minimal and connected version of the ConnectorSelector component used in the Settings modal. + */ +export const ConnectorSelectorInline: React.FC = React.memo( + ({ + isDisabled = false, + onConnectorModalVisibilityChange, + selectedConnectorId, + selectedConversation, + onConnectorSelectionChange, + }) => { + const [isOpen, setIsOpen] = useState(false); + const { actionTypeRegistry, http } = useAssistantContext(); + const { setApiConfig } = useConversation(); + // Connector Modal State + const [isConnectorModalVisible, setIsConnectorModalVisible] = useState(false); + const { data: actionTypes } = useLoadActionTypes({ http }); + const actionType = actionTypes?.find((at) => at.id === GEN_AI_CONNECTOR_ID) ?? { + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['general'], + isSystemActionType: false, + id: '.gen-ai', + name: 'Generative AI', + enabled: true, + }; + + const { + data: connectors, + isLoading: isLoadingActionTypes, + isFetching: isFetchingActionTypes, + refetch: refetchConnectors, + } = useLoadConnectors({ http }); + const isLoading = isLoadingActionTypes || isFetchingActionTypes; + const selectedConnectorName = + connectors?.find((c) => c.id === selectedConnectorId)?.name ?? + i18n.INLINE_CONNECTOR_PLACEHOLDER; + + const addNewConnectorOption = useMemo(() => { + return { + value: ADD_NEW_CONNECTOR, + inputDisplay: i18n.ADD_NEW_CONNECTOR, + dropdownDisplay: ( + + + + {i18n.ADD_NEW_CONNECTOR} + + + + {/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/} +

+ + + ), + }; + }, []); + + const connectorOptions = useMemo(() => { + return ( + connectors?.map((connector) => { + const apiProvider: string | undefined = ( + connector as ActionConnectorProps + )?.config?.apiProvider; + return { + value: connector.id, + inputDisplay: ( + + {connector.name} + + ), + dropdownDisplay: ( + + {connector.name} + {apiProvider && ( + +

{apiProvider}

+
+ )} +
+ ), + }; + }) ?? [] + ); + }, [connectors]); + + const cleanupAndCloseModal = useCallback(() => { + onConnectorModalVisibilityChange?.(false); + setIsConnectorModalVisible(false); + }, [onConnectorModalVisibilityChange]); + + const onConnectorClick = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const handleOnBlur = useCallback(() => setIsOpen(false), []); + + const onChange = useCallback( + (connectorId: string, apiProvider?: OpenAiProviderType) => { + setIsOpen(false); + + if (connectorId === ADD_NEW_CONNECTOR) { + onConnectorModalVisibilityChange?.(true); + setIsConnectorModalVisible(true); + return; + } + + const provider = + apiProvider ?? + ((connectors?.find((c) => c.id === connectorId) as ActionConnectorProps) + ?.config.apiProvider as OpenAiProviderType); + + if (selectedConversation != null) { + setApiConfig({ + conversationId: selectedConversation.id, + apiConfig: { + ...selectedConversation.apiConfig, + connectorId, + provider, + }, + }); + } + + onConnectorSelectionChange(connectorId, provider); + }, + [ + connectors, + selectedConversation, + onConnectorSelectionChange, + onConnectorModalVisibilityChange, + setApiConfig, + ] + ); + + const placeholderComponent = useMemo( + () => ( + + {i18n.INLINE_CONNECTOR_PLACEHOLDER} + + ), + [] + ); + + return ( + + + + {i18n.INLINE_CONNECTOR_LABEL} + + + + {isOpen ? ( + + ) : ( + + + {selectedConnectorName} + + + )} + {isConnectorModalVisible && ( + { + const provider = (savedAction as ActionConnectorProps)?.config + .apiProvider as OpenAiProviderType; + onChange(savedAction.id, provider); + onConnectorSelectionChange(savedAction.id, provider); + refetchConnectors?.(); + cleanupAndCloseModal(); + }} + actionTypeRegistry={actionTypeRegistry} + /> + )} + + + ); + } +); + +ConnectorSelectorInline.displayName = 'ConnectorSelectorInline'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts index df6343f62b4e2..2c8523f2966b9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -45,6 +45,20 @@ export const ADD_NEW_CONNECTOR = i18n.translate( } ); +export const INLINE_CONNECTOR_LABEL = i18n.translate( + 'xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorLabel', + { + defaultMessage: 'Connector:', + } +); + +export const INLINE_CONNECTOR_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorPlaceholder', + { + defaultMessage: 'Select a Connector', + } +); + export const ADD_CONNECTOR_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.connectors.addConnectorButton.title', {