[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:
<p align="center">
<img width="500"
src="83e6a884-103f-43c4-9a30-a0281d9941a2"
/>
</p> 


Overflow: 
<p align="center">
<img width="500"
src="f0d8a04e-963d-4053-90f5-2417f1c8eaca"
/>
</p> 


Missing:
<p align="center">
<img width="500"
src="eff04e75-a5ab-468c-b801-1e056d527e6a"
/>
</p> 


Open:
<p align="center">
<img width="500"
src="b7b97244-91a5-41ec-a096-b296e0cde644"
/>
</p> 



### 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/))
This commit is contained in:
Garrett Spong 2023-08-16 04:13:10 -06:00 committed by GitHub
parent d0231b995a
commit 847e0cbe72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 494 additions and 31 deletions

View file

@ -80,7 +80,11 @@ export const AssistantHeader: React.FC<Props> = ({
justifyContent={'spaceBetween'}
>
<EuiFlexItem grow={false}>
<AssistantTitle {...currentTitle} docLinks={docLinks} />
<AssistantTitle
{...currentTitle}
docLinks={docLinks}
selectedConversation={currentConversation}
/>
</EuiFlexItem>
<EuiFlexItem

View file

@ -14,18 +14,29 @@ const testProps = {
title: 'Test Title',
titleIcon: 'globe',
docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: '7.15' },
selectedConversation: undefined,
};
describe('AssistantTitle', () => {
it('the component renders correctly with valid props', () => {
const { getByText, container } = render(<AssistantTitle {...testProps} />);
const { getByText, container } = render(
<TestProviders>
<AssistantTitle {...testProps} />
</TestProviders>
);
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(<AssistantTitle {...testProps} />, {
wrapper: TestProviders,
});
const { getByTestId, queryByTestId } = render(
<TestProviders>
<AssistantTitle {...testProps} />
</TestProviders>,
{
wrapper: TestProviders,
}
);
expect(queryByTestId('tooltipContent')).not.toBeInTheDocument();
fireEvent.click(getByTestId('tooltipIcon'));
expect(getByTestId('tooltipContent')).toBeInTheDocument();

View file

@ -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<DocLinksStart, 'links'>;
}> = ({ 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 (
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem
grow={false}
css={css`
margin-top: 3px;
`}
>
<EuiIcon data-test-subj="titleIcon" type={titleIcon} size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{title}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={i18n.TOOLTIP_ARIA_LABEL}
data-test-subj="tooltipIcon"
iconSize="l"
iconType="iInCircle"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="upCenter"
>
<EuiText data-test-subj="tooltipContent" grow={false} css={{ maxWidth: '400px' }}>
<h4>{i18n.TOOLTIP_TITLE}</h4>
<p>{content}</p>
</EuiText>
</EuiPopover>
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size={'s'}>
<h3>{title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={i18n.TOOLTIP_ARIA_LABEL}
data-test-subj="tooltipIcon"
iconType="iInCircle"
onClick={onButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="rightUp"
>
<EuiText data-test-subj="tooltipContent" grow={false} css={{ maxWidth: '400px' }}>
<h4>{i18n.TOOLTIP_TITLE}</h4>
<EuiText size={'s'}>
<p>{content}</p>
</EuiText>
</EuiText>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConnectorSelectorInline
isDisabled={selectedConversation === undefined}
onConnectorModalVisibilityChange={() => {}}
onConnectorSelectionChange={() => {}}
selectedConnectorId={selectedConnectorId}
selectedConversation={selectedConversation}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiModalHeaderTitle>
);

View file

@ -46,7 +46,7 @@ export const ConnectorMissingCallout: React.FC<Props> = React.memo(
<p>
{' '}
<FormattedMessage
defaultMessage="Select a connector from the {link} to continue"
defaultMessage="Select a connector above or from the {link} to continue"
id="xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutDescription"
values={{
link: (

View file

@ -0,0 +1,116 @@
/*
* 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 { render } from '@testing-library/react';
import { noop } from 'lodash/fp';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { ConnectorSelectorInline } from './connector_selector_inline';
import * as i18n from '../translations';
import { Conversation } from '../../..';
import { useLoadConnectors } from '../use_load_connectors';
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/constants', () => ({
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(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
onConnectorModalVisibilityChange={noop}
onConnectorSelectionChange={noop}
selectedConnectorId={undefined}
selectedConversation={undefined}
/>
</TestProviders>
);
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(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
onConnectorModalVisibilityChange={noop}
onConnectorSelectionChange={noop}
selectedConnectorId={'missing-connector-id'}
selectedConversation={conversation}
/>
</TestProviders>
);
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(
<TestProviders>
<ConnectorSelectorInline
isDisabled={false}
onConnectorModalVisibilityChange={noop}
onConnectorSelectionChange={noop}
selectedConnectorId={mockConnectors[0].id}
selectedConversation={conversation}
/>
</TestProviders>
);
expect(getByText(mockConnectors[0].name)).toBeInTheDocument();
});
});

View file

@ -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<Props> = React.memo(
({
isDisabled = false,
onConnectorModalVisibilityChange,
selectedConnectorId,
selectedConversation,
onConnectorSelectionChange,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { actionTypeRegistry, http } = useAssistantContext();
const { setApiConfig } = useConversation();
// Connector Modal State
const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(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: (
<EuiFlexGroup gutterSize="none" key={ADD_NEW_CONNECTOR}>
<EuiFlexItem grow={true}>
<EuiButtonEmpty data-test-subj="addNewConnectorButton" iconType="plus" size="xs">
{i18n.ADD_NEW_CONNECTOR}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/}
<div style={{ width: '24px' }} />
</EuiFlexItem>
</EuiFlexGroup>
),
};
}, []);
const connectorOptions = useMemo(() => {
return (
connectors?.map((connector) => {
const apiProvider: string | undefined = (
connector as ActionConnectorProps<Config, unknown>
)?.config?.apiProvider;
return {
value: connector.id,
inputDisplay: (
<EuiText className={inputDisplayClassName} size="xs">
{connector.name}
</EuiText>
),
dropdownDisplay: (
<React.Fragment key={connector.id}>
<strong>{connector.name}</strong>
{apiProvider && (
<EuiText size="xs" color="subdued">
<p>{apiProvider}</p>
</EuiText>
)}
</React.Fragment>
),
};
}) ?? []
);
}, [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, unknown>)
?.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(
() => (
<EuiText color="default" size={'xs'}>
{i18n.INLINE_CONNECTOR_PLACEHOLDER}
</EuiText>
),
[]
);
return (
<EuiFlexGroup
alignItems="center"
className={inputContainerClassName}
direction="row"
gutterSize="xs"
justifyContent={'flexStart'}
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{i18n.INLINE_CONNECTOR_LABEL}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
{isOpen ? (
<EuiSuperSelect
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
compressed={true}
disabled={isDisabled}
hasDividers={true}
isLoading={isLoading}
isOpen={isOpen}
onBlur={handleOnBlur}
onChange={onChange}
options={[...connectorOptions, addNewConnectorOption]}
placeholder={placeholderComponent}
valueOfSelected={selectedConnectorId}
/>
) : (
<span>
<EuiButtonEmpty
className={placeholderButtonClassName}
color={'text'}
data-test-subj="connectorSelectorPlaceholderButton"
iconSide={'right'}
iconType="arrowDown"
isDisabled={isDisabled}
onClick={onConnectorClick}
size="xs"
>
{selectedConnectorName}
</EuiButtonEmpty>
</span>
)}
{isConnectorModalVisible && (
<ConnectorAddModal
actionType={actionType}
onClose={cleanupAndCloseModal}
postSaveEventHandler={(savedAction: ActionConnector) => {
const provider = (savedAction as ActionConnectorProps<Config, unknown>)?.config
.apiProvider as OpenAiProviderType;
onChange(savedAction.id, provider);
onConnectorSelectionChange(savedAction.id, provider);
refetchConnectors?.();
cleanupAndCloseModal();
}}
actionTypeRegistry={actionTypeRegistry}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ConnectorSelectorInline.displayName = 'ConnectorSelectorInline';

View file

@ -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',
{