Skip to content
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
Expand Up @@ -6,10 +6,10 @@
*/

import React from 'react';
import { render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { NewChatByTitle } from '.';
import { BUTTON_ICON_TEST_ID, BUTTON_TEST_ID, BUTTON_TEXT_TEST_ID, NewChatByTitle } from '.';

const testProps = {
showAssistantOverlay: jest.fn(),
Expand All @@ -20,60 +20,28 @@ describe('NewChatByTitle', () => {
jest.clearAllMocks();
});

it('renders the default New Chat button with a discuss icon', () => {
render(<NewChatByTitle {...testProps} />);
it('should render icon only by default', () => {
const { getByTestId, queryByTestId } = render(<NewChatByTitle {...testProps} />);

const newChatButton = screen.getByTestId('newChatByTitle');

expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
});

it('renders the default "New Chat" text when children are NOT provided', () => {
render(<NewChatByTitle {...testProps} />);

const newChatButton = screen.getByTestId('newChatByTitle');

expect(newChatButton.textContent).toContain('Chat');
expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(BUTTON_TEXT_TEST_ID)).not.toBeInTheDocument();
});

it('renders custom children', async () => {
render(<NewChatByTitle {...testProps}>{'🪄✨'}</NewChatByTitle>);

const newChatButton = screen.getByTestId('newChatByTitle');

expect(newChatButton.textContent).toContain('🪄✨');
});

it('renders custom icons', async () => {
render(<NewChatByTitle {...testProps} iconType="help" />);

const newChatButton = screen.getByTestId('newChatByTitle');
it('should render the button with icon and text', () => {
const { getByTestId } = render(<NewChatByTitle {...testProps} text={'Ask AI Assistant'} />);

expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument();
});

it('does NOT render an icon when iconType is null', () => {
render(<NewChatByTitle {...testProps} iconType={null} />);

const newChatButton = screen.getByTestId('newChatByTitle');

expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument();
});

it('renders button icon when iconOnly is true', async () => {
render(<NewChatByTitle {...testProps} iconOnly />);

const newChatButton = screen.getByTestId('newChatByTitle');

expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
expect(newChatButton.textContent).not.toContain('Chat');
expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(BUTTON_TEXT_TEST_ID)).toHaveTextContent('Ask AI Assistant');
});

it('calls showAssistantOverlay on click', async () => {
render(<NewChatByTitle {...testProps} />);
const newChatButton = screen.getByTestId('newChatByTitle');
const { getByTestId } = render(<NewChatByTitle {...testProps} />);

const button = getByTestId(BUTTON_TEST_ID);

await userEvent.click(newChatButton);
await userEvent.click(button);

expect(testProps.showAssistantOverlay).toHaveBeenCalledWith(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,76 +5,78 @@
* 2.0.
*/

import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';

import type { EuiButtonColor } from '@elastic/eui';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback } from 'react';
import { AssistantIcon } from '@kbn/ai-assistant-icon';
import { EuiButtonEmptySizes } from '@elastic/eui/src/components/button/button_empty/button_empty';
import * as i18n from './translations';

export interface Props {
children?: React.ReactNode;
/** Defaults to `discuss`. If null, the button will not have an icon */
iconType?: string | null;
export const BUTTON_TEST_ID = 'newChatByTitle';
export const BUTTON_ICON_TEST_ID = 'newChatByTitleIcon';
export const BUTTON_TEXT_TEST_ID = 'newChatByTitleText';

export interface NewChatByTitleComponentProps {
/**
* Optionally specify color of empty button.
* @default 'primary'
*/
color?: EuiButtonColor;
/**
* Callback to display the assistant overlay
*/
showAssistantOverlay: (show: boolean) => void;
/** Defaults to false. If true, shows icon button without text */
iconOnly?: boolean;
/**
*
*/
size?: EuiButtonEmptySizes;
/**
* Optionally specify the text to display.
*/
text?: string;
}

const NewChatByTitleComponent: React.FC<Props> = ({
children = i18n.NEW_CHAT,
iconType,
const NewChatByTitleComponent: React.FC<NewChatByTitleComponentProps> = ({
color = 'primary',
showAssistantOverlay,
iconOnly = false,
size = 'm',
text,
}) => {
const showOverlay = useCallback(() => {
showAssistantOverlay(true);
}, [showAssistantOverlay]);

const icon = useMemo(() => {
if (iconType === null) {
return undefined;
}

return iconType ?? 'discuss';
}, [iconType]);
const showOverlay = useCallback(() => showAssistantOverlay(true), [showAssistantOverlay]);

return useMemo(
() =>
iconOnly ? (
<EuiToolTip content={i18n.NEW_CHAT}>
<EuiButtonIcon
data-test-subj="newChatByTitle"
iconType={icon ?? 'discuss'}
onClick={showOverlay}
color={'text'}
aria-label={i18n.NEW_CHAT}
/>
</EuiToolTip>
) : (
<EuiButtonEmpty
data-test-subj="newChatByTitle"
iconType={icon}
onClick={showOverlay}
aria-label={i18n.NEW_CHAT}
>
{children}
</EuiButtonEmpty>
),
[children, icon, showOverlay, iconOnly]
return (
<EuiButtonEmpty
aria-label={i18n.ASK_AI_ASSISTANT}
color={color}
data-test-subj={BUTTON_TEST_ID}
onClick={showOverlay}
size={size}
>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<AssistantIcon data-test-subj={BUTTON_ICON_TEST_ID} size="m" />
</EuiFlexItem>
{text && (
<EuiFlexItem data-test-subj={BUTTON_TEXT_TEST_ID} grow={false}>
{text}
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiButtonEmpty>
);
};

NewChatByTitleComponent.displayName = 'NewChatByTitleComponent';

/**
* `NewChatByTitle` displays a _New chat_ icon button by providing only the `promptContextId`
* `NewChatByTitle` displays a button by providing only the `promptContextId`
* of a context that was (already) registered by the `useAssistantOverlay` hook. You may
* optionally style the button icon, or override the default _New chat_ text with custom
* content, like {'🪄✨'}
* optionally override the default text.
*
* USE THIS WHEN: all the data necessary to start a new chat is NOT available
* in the same part of the React tree as the _New chat_ button. When paired
* with the `useAssistantOverlay` hook, this option enables context to be be
* registered where the data is available, and then the _New chat_ button can be displayed
* in the same part of the React tree as the button. When paired
* with the `useAssistantOverlay` hook, this option enables context to be
* registered where the data is available, and then the button can be displayed
* in another part of the tree.
*/
export const NewChatByTitle = React.memo(NewChatByTitleComponent);
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

import { i18n } from '@kbn/i18n';

export const NEW_CHAT = i18n.translate(
export const ASK_AI_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.newChatByTitle.newChatByTitleButton',
{
defaultMessage: 'Chat',
defaultMessage: 'Ask AI Assistant',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { ActionsCell } from './actions_cell';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { MORE_ACTIONS_BUTTON_TEST_ID } from './more_actions_row_control_column';
import { useAssistant } from '../../../hooks/alert_summary/use_assistant';
import { useAddToCaseActions } from '../../alerts_table/timeline_actions/use_add_to_case_actions';
import { useAlertTagsActions } from '../../alerts_table/timeline_actions/use_alert_tags_actions';
import { ROW_ACTION_FLYOUT_ICON_TEST_ID } from './open_flyout_row_control_column';

jest.mock('@kbn/expandable-flyout');
jest.mock('../../../hooks/alert_summary/use_assistant');
jest.mock('../../alerts_table/timeline_actions/use_add_to_case_actions');
jest.mock('../../alerts_table/timeline_actions/use_alert_tags_actions');

Expand All @@ -25,6 +27,10 @@ describe('ActionsCell', () => {
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openFlyout: jest.fn(),
});
(useAssistant as jest.Mock).mockReturnValue({
showAssistant: true,
showAssistantOverlay: jest.fn(),
});
(useAddToCaseActions as jest.Mock).mockReturnValue({
addToCaseActionItems: [],
});
Expand All @@ -45,6 +51,7 @@ describe('ActionsCell', () => {
const { getByTestId } = render(<ActionsCell alert={alert} ecsAlert={ecsAlert} />);

expect(getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID)).toBeInTheDocument();
expect(getByTestId('newChatByTitle')).toBeInTheDocument();
expect(getByTestId(MORE_ACTIONS_BUTTON_TEST_ID)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import React, { memo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { Alert } from '@kbn/alerting-types';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { OpenFlyoutRowControlColumn } from './open_flyout_row_control_column';
import { AssistantRowControlColumn } from './assistant_row_control_column';
import { MoreActionsRowControlColumn } from './more_actions_row_control_column';
import { OpenFlyoutRowControlColumn } from './open_flyout_row_control_column';

export interface ActionsCellProps {
/**
Expand All @@ -28,14 +29,17 @@ export interface ActionsCellProps {
* It is passed to the renderActionsCell property of the EuiDataGrid.
* It renders all the icons in the row action icons:
* - open flyout
* - assistant (soon)
* - more actions (soon)
* - assistant
* - more actions
*/
export const ActionsCell = memo(({ alert, ecsAlert }: ActionsCellProps) => (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<OpenFlyoutRowControlColumn alert={alert} />
</EuiFlexItem>
<EuiFlexItem>
<AssistantRowControlColumn alert={alert} />
</EuiFlexItem>
<EuiFlexItem>
<MoreActionsRowControlColumn ecsAlert={ecsAlert} />
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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 { AssistantRowControlColumn } from './assistant_row_control_column';
import type { Alert } from '@kbn/alerting-types';
import { useAssistant } from '../../../hooks/alert_summary/use_assistant';

jest.mock('../../../hooks/alert_summary/use_assistant');

describe('AssistantRowControlColumn', () => {
it('should render the icon button', () => {
(useAssistant as jest.Mock).mockReturnValue({
showAssistantOverlay: jest.fn(),
});

const alert: Alert = {
_id: '_id',
_index: '_index',
};

const { getByTestId } = render(<AssistantRowControlColumn alert={alert} />);

expect(getByTestId('newChatByTitle')).toBeInTheDocument();
expect(getByTestId('newChatByTitleIcon')).toBeInTheDocument();
});

it('should call the callback when clicked', () => {
const showAssistantOverlay = jest.fn();
(useAssistant as jest.Mock).mockReturnValue({
showAssistantOverlay,
});

const alert: Alert = {
_id: '_id',
_index: '_index',
};

const { getByTestId } = render(<AssistantRowControlColumn alert={alert} />);

const button = getByTestId('newChatByTitle');
expect(button).toBeInTheDocument();

button.click();

expect(showAssistantOverlay).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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, { memo } from 'react';
import type { Alert } from '@kbn/alerting-types';
import { NewChatByTitle } from '@kbn/elastic-assistant/impl/new_chat_by_title';
import { useAssistant } from '../../../hooks/alert_summary/use_assistant';

export interface AssistantRowControlColumnProps {
/**
* Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface
*/
alert: Alert;
}

/**
* Renders the assistant icon and opens the assistant flyout for the current alert when clicked.
* This is used in the AI for SOC alert summary table.
*/
export const AssistantRowControlColumn = memo(({ alert }: AssistantRowControlColumnProps) => {
const { showAssistantOverlay } = useAssistant({ alert });

return <NewChatByTitle showAssistantOverlay={showAssistantOverlay} size="xs" />;
});

AssistantRowControlColumn.displayName = 'AssistantRowControlColumn';
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const columns: EuiDataGridProps['columns'] = [
},
];

const ACTION_COLUMN_WIDTH = 72; // px
const ACTION_COLUMN_WIDTH = 98; // px
const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM];
const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID];
const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 };
Expand Down
Loading