Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] Adds new Elastic AI Assistant icon and global header menu item #164763

Merged
merged 3 commits into from
Aug 26, 2023
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
@@ -0,0 +1,46 @@
/*
* 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, { ReactNode } from 'react';

export interface AssistantAvatarProps {
size?: keyof typeof sizeMap;
// Required for EuiAvatar `iconType` prop
// eslint-disable-next-line react/no-unused-prop-types
children?: ReactNode;
}

export const sizeMap = {
xl: 64,
l: 48,
m: 32,
s: 24,
xs: 16,
};

/**
* Default Elastic AI Assistant logo
*
* TODO: Can be removed once added to EUI
*/
export const AssistantAvatar = ({ size = 's' }: AssistantAvatarProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={sizeMap[size]}
height={sizeMap[size]}
viewBox="0 0 64 64"
fill="none"
>
<path fill="#F04E98" d="M36 28h24v36H36V28Z" />
<path fill="#00BFB3" d="M4 46c0-9.941 8.059-18 18-18h6v36h-6c-9.941 0-18-8.059-18-18Z" />
<path
fill="#343741"
d="M60 12c0 6.627-5.373 12-12 12s-12-5.373-12-12S41.373 0 48 0s12 5.373 12 12Z"
/>
<path fill="#FA744E" d="M6 23C6 10.85 15.85 1 28 1v22H6Z" />
</svg>
);
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import { alertConvo, emptyWelcomeConvo } from '../../mock/conversation';

const testProps = {
currentConversation: emptyWelcomeConvo,
currentTitle: {
title: 'Test Title',
titleIcon: 'logoSecurity',
},
title: 'Test Title',
docLinks: {
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'master',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import * as i18n from '../translations';

interface OwnProps {
currentConversation: Conversation;
currentTitle: { title: string | JSX.Element; titleIcon: string };
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
docLinks: Omit<DocLinksStart, 'links'>;
Expand All @@ -39,6 +38,7 @@ interface OwnProps {
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
shouldDisableKeyboardShortcut?: () => boolean;
showAnonymizedValues: boolean;
title: string | JSX.Element;
}

type Props = OwnProps;
Expand All @@ -49,7 +49,6 @@ type Props = OwnProps;
*/
export const AssistantHeader: React.FC<Props> = ({
currentConversation,
currentTitle,
defaultConnectorId,
defaultProvider,
docLinks,
Expand All @@ -62,6 +61,7 @@ export const AssistantHeader: React.FC<Props> = ({
setSelectedConversationId,
shouldDisableKeyboardShortcut,
showAnonymizedValues,
title,
}) => {
const showAnonymizedValuesChecked = useMemo(
() =>
Expand All @@ -81,10 +81,10 @@ export const AssistantHeader: React.FC<Props> = ({
>
<EuiFlexItem grow={false}>
<AssistantTitle
{...currentTitle}
isDisabled={isDisabled}
docLinks={docLinks}
selectedConversation={currentConversation}
title={title}
/>
</EuiFlexItem>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,18 @@ import { TestProviders } from '../../mock/test_providers/test_providers';

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(
const { getByText } = 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiModalHeaderTitle,
EuiPopover,
Expand All @@ -19,22 +18,21 @@ import {
} 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';
import { AssistantAvatar } from '../assistant_avatar/assistant_avatar';

/**
* Renders a header title with an icon, a tooltip button, and a popover with
* Renders a header title, a tooltip button, and a popover with
* information about the assistant feature and access to documentation.
*/
export const AssistantTitle: React.FC<{
isDisabled?: boolean;
title: string | JSX.Element;
titleIcon: string;
docLinks: Omit<DocLinksStart, 'links'>;
selectedConversation: Conversation | undefined;
}> = ({ isDisabled = false, title, titleIcon, docLinks, selectedConversation }) => {
}> = ({ isDisabled = false, title, docLinks, selectedConversation }) => {
const selectedConnectorId = selectedConversation?.apiConfig?.connectorId;

const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
Expand Down Expand Up @@ -75,13 +73,8 @@ export const AssistantTitle: React.FC<{
return (
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem
grow={false}
css={css`
margin-top: 3px;
`}
>
<EuiIcon data-test-subj="titleIcon" type={titleIcon} size="xl" />
<EuiFlexItem grow={false}>
<AssistantAvatar data-test-subj="titleIcon" size={'m'} />
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
Expand Down
11 changes: 3 additions & 8 deletions x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,8 @@ const AssistantComponent: React.FC<Props> = ({
},
});

const currentTitle: { title: string | JSX.Element; titleIcon: string } =
isWelcomeSetup && blockBotConversation.theme?.title && blockBotConversation.theme?.titleIcon
? {
title: blockBotConversation.theme?.title,
titleIcon: blockBotConversation.theme?.titleIcon,
}
: { title, titleIcon: 'logoSecurity' };
const currentTitle: string | JSX.Element =
isWelcomeSetup && blockBotConversation.theme?.title ? blockBotConversation.theme?.title : title;

const bottomRef = useRef<HTMLDivElement | null>(null);
const lastCommentRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -426,7 +421,6 @@ const AssistantComponent: React.FC<Props> = ({
{showTitle && (
<AssistantHeader
currentConversation={currentConversation}
currentTitle={currentTitle}
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
docLinks={docLinks}
Expand All @@ -438,6 +432,7 @@ const AssistantComponent: React.FC<Props> = ({
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
showAnonymizedValues={showAnonymizedValues}
title={currentTitle}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { clearPresentationData, conversationHasNoPresentationData } from './help
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../use_load_connectors';
import { AssistantAvatar } from '../../assistant/assistant_avatar/assistant_avatar';

const ConnectorButtonWrapper = styled.div`
margin-bottom: 10px;
Expand Down Expand Up @@ -186,21 +187,14 @@ export const useConnectorSetup = ({
name={i18n.CONNECTOR_SETUP_USER_ASSISTANT}
size="l"
color="subdued"
iconType={conversation?.theme?.assistant?.icon ?? 'logoElastic'}
iconType={AssistantAvatar}
/>
),
timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`,
};
return commentProps;
}),
[
assistantName,
commentBody,
conversation.messages,
conversation?.theme?.assistant?.icon,
currentMessageIndex,
userName,
]
[assistantName, commentBody, conversation.messages, currentMessageIndex, userName]
);

return {
Expand Down
3 changes: 3 additions & 0 deletions x-pack/packages/kbn-elastic-assistant/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay';
/** a helper that enriches content returned from a query with action buttons */
export { analyzeMarkdown } from './impl/assistant/use_conversation/helpers';

/** Default Elastic AI Assistant logo, can be removed once included in EUI **/
export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_avatar';

export {
ELASTIC_AI_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiToolTip } from '@elastic/eui';
import React, { useCallback } from 'react';

import { i18n } from '@kbn/i18n';
import { useAssistantContext } from '@kbn/elastic-assistant/impl/assistant_context';
import { AssistantAvatar } from '@kbn/elastic-assistant';

const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;

/**
* Elastic AI Assistant header link
*/
export const AssistantHeaderLink = React.memo(() => {
const { showAssistantOverlay } = useAssistantContext();

const keyboardShortcut = isMac ? '⌘ ;' : 'Ctrl ;';

const tooltipContent = i18n.translate(
'xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip',
{
values: { keyboardShortcut },
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
}
);

const showOverlay = useCallback(
() => showAssistantOverlay({ showOverlay: true }),
[showAssistantOverlay]
);

return (
<EuiToolTip content={tooltipContent}>
<EuiHeaderLink data-test-subj="assistantHeaderLink" color="primary" onClick={showOverlay}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantAvatar size="xs" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{i18n.translate('xpack.securitySolution.globalHeader.assistantHeaderLink', {
defaultMessage: 'AI Assistant',
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiHeaderLink>
</EuiToolTip>
);
});

AssistantHeaderLink.displayName = 'AssistantHeaderLink';
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { useVariationMock } from '../../../common/components/utils.mocks';
import { GlobalHeader } from '.';
Expand All @@ -26,6 +26,7 @@ import { TimelineId } from '../../../../common/types/timeline';
import { createStore } from '../../../common/store';
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
import { sourcererPaths } from '../../../common/containers/sourcerer';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
Expand All @@ -48,6 +49,15 @@ jest.mock('react-reverse-portal', () => ({
createHtmlPortalNode: () => ({ unmount: jest.fn() }),
}));

jest.mock('../../../assistant/use_assistant_availability');

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

describe('global header', () => {
const mockSetHeaderActionMenu = jest.fn();
const state = {
Expand Down Expand Up @@ -172,4 +182,46 @@ describe('global header', () => {

expect(queryByTestId('sourcerer-trigger')).not.toBeInTheDocument();
});

it('shows AI Assistant header link if user has necessary privileges', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: true,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

const { findByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);

waitFor(() => expect(findByTestId('assistantHeaderLink')).toBeInTheDocument());
});

it('does not show AI Assistant header link if user does not have necessary privileges', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

const { findByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);

waitFor(() => expect(findByTestId('assistantHeaderLink')).not.toBeInTheDocument());
});
});
Loading