[Security Solution] expandable flyout - add loading and no data states to Investigation guide in right and left panels (#164876)

This commit is contained in:
Philippe Oberti 2023-08-26 12:23:03 +02:00 committed by GitHub
parent 225fc95488
commit dc8971d2c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 527 additions and 239 deletions

View file

@ -171,8 +171,8 @@ const EventDetailsComponent: React.FC<Props> = ({
const goToTableTab = useCallback(() => setSelectedTabId(EventsViewType.tableView), []);
const eventFields = useMemo(() => getEnrichmentFields(data), [data]);
const { ruleId } = useBasicDataFromDetailsData(data);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const basicAlertData = useBasicDataFromDetailsData(data);
const { rule: maybeRule } = useRuleWithFallback(basicAlertData.ruleId);
const existingEnrichments = useMemo(
() =>
isAlert
@ -222,6 +222,7 @@ const EventDetailsComponent: React.FC<Props> = ({
const endpointResponseActionsEnabled = useIsExperimentalFeatureEnabled(
'endpointResponseActionsEnabled'
);
const summaryTab: EventViewTab | undefined = useMemo(
() =>
isAlert
@ -319,7 +320,9 @@ const EventDetailsComponent: React.FC<Props> = ({
</>
)}
<InvestigationGuideView data={data} />
{basicAlertData.ruleId && maybeRule?.note && (
<InvestigationGuideView basicData={basicAlertData} ruleNote={maybeRule.note} />
)}
</>
),
}
@ -332,17 +335,19 @@ const EventDetailsComponent: React.FC<Props> = ({
id,
handleOnEventClosed,
isReadOnly,
threatDetails,
renderer,
detailsEcsData,
isDraggable,
goToTableTab,
threatDetails,
maybeRule?.investigation_fields,
maybeRule?.note,
showThreatSummary,
hostRisk,
userRisk,
allEnrichments,
isEnrichmentsLoading,
maybeRule,
basicAlertData,
]
);

View file

@ -8,27 +8,17 @@
import React from 'react';
import { render } from '@testing-library/react';
import { InvestigationGuideView } from './investigation_guide_view';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
const mockUseRuleWithFallback = useRuleWithFallback as jest.Mock;
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
import type { GetBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
const defaultProps = {
data: [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
values: ['rule-uuid'],
originalValue: ['rule-uuid'],
isObjectArray: false,
},
],
basicData: {
ruleId: 'rule-id',
} as unknown as GetBasicDataFromDetailsData,
ruleNote: 'test note',
};
describe('Investigation guide view', () => {
it('should render title and clamped investigation guide (with read more/read less) by default', () => {
mockUseRuleWithFallback.mockReturnValue({ rule: { note: 'test note' } });
const { getByTestId, queryByTestId } = render(<InvestigationGuideView {...defaultProps} />);
expect(getByTestId('summary-view-guide')).toBeInTheDocument();
@ -37,8 +27,6 @@ describe('Investigation guide view', () => {
});
it('should render full investigation guide when showFullView is true', () => {
mockUseRuleWithFallback.mockReturnValue({ rule: { note: 'test note' } });
const { getByTestId, queryByTestId } = render(
<InvestigationGuideView {...defaultProps} showFullView={true} />
);
@ -47,19 +35,6 @@ describe('Investigation guide view', () => {
expect(queryByTestId('investigation-guide-clamped')).not.toBeInTheDocument();
});
it('should not render investigation guide when rule id is not available', () => {
const { queryByTestId } = render(<InvestigationGuideView {...defaultProps} data={[]} />);
expect(queryByTestId('investigation-guide-clamped')).not.toBeInTheDocument();
expect(queryByTestId('investigation-guide-full-view')).not.toBeInTheDocument();
});
it('should not render investigation guide button when investigation guide is not available', () => {
mockUseRuleWithFallback.mockReturnValue({ rule: {} });
const { queryByTestId } = render(<InvestigationGuideView {...defaultProps} />);
expect(queryByTestId('investigation-guide-clamped')).not.toBeInTheDocument();
expect(queryByTestId('investigation-guide-full-view')).not.toBeInTheDocument();
});
it('should not render investigation guide title when showTitle is false', () => {
const { queryByTestId } = render(
<InvestigationGuideView {...defaultProps} showTitle={false} />

View file

@ -9,12 +9,9 @@ import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui';
import React, { createContext } from 'react';
import styled from 'styled-components';
import type { GetBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import * as i18n from './translations';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { MarkdownRenderer } from '../markdown_editor';
import { LineClamp } from '../line_clamp';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
export const Indent = styled.div`
padding: 0 8px;
@ -25,9 +22,13 @@ export const BasicAlertDataContext = createContext<Partial<GetBasicDataFromDetai
interface InvestigationGuideViewProps {
/**
* An array of events data
* An object of basic fields from the event details data
*/
data: TimelineEventsDetailsItem[];
basicData: GetBasicDataFromDetailsData;
/**
* The markdown text of rule.note
*/
ruleNote: string;
/**
* Boolean value indicating whether to show the full view of investigation guide, defaults to false and shows partial text
* with Read more button
@ -43,19 +44,13 @@ interface InvestigationGuideViewProps {
* Investigation guide that shows the markdown text of rule.note
*/
const InvestigationGuideViewComponent: React.FC<InvestigationGuideViewProps> = ({
data,
basicData,
ruleNote,
showFullView = false,
showTitle = true,
}) => {
const basicAlertData = useBasicDataFromDetailsData(data);
const { rule: maybeRule } = useRuleWithFallback(basicAlertData.ruleId);
if (!basicAlertData.ruleId || !maybeRule?.note) {
return null;
}
return (
<BasicAlertDataContext.Provider value={basicAlertData}>
<BasicAlertDataContext.Provider value={basicData}>
{showTitle && (
<>
<EuiSpacer size="l" />
@ -68,12 +63,12 @@ const InvestigationGuideViewComponent: React.FC<InvestigationGuideViewProps> = (
<Indent>
{showFullView ? (
<EuiText size="xs" data-test-subj="investigation-guide-full-view">
<MarkdownRenderer>{maybeRule.note}</MarkdownRenderer>
<MarkdownRenderer>{ruleNote}</MarkdownRenderer>
</EuiText>
) : (
<EuiText size="xs" data-test-subj="investigation-guide-clamped">
<LineClamp lineClampHeight={4.5}>
<MarkdownRenderer>{maybeRule.note}</MarkdownRenderer>
<MarkdownRenderer>{ruleNote}</MarkdownRenderer>
</LineClamp>
</EuiText>
)}

View file

@ -0,0 +1,90 @@
/*
* 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 { InvestigationGuide } from './investigation_guide';
import { LeftPanelContext } from '../context';
import { TestProviders } from '../../../common/mock';
import {
INVESTIGATION_GUIDE_LOADING_TEST_ID,
INVESTIGATION_GUIDE_NO_DATA_TEST_ID,
} from './test_ids';
import { mockContextValue } from '../mocks/mock_context';
import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide';
jest.mock('../../shared/hooks/use_investigation_guide');
const renderInvestigationGuide = (context: LeftPanelContext = mockContextValue) => (
<TestProviders>
<LeftPanelContext.Provider value={context}>
<InvestigationGuide />
</LeftPanelContext.Provider>
</TestProviders>
);
describe('<InvestigationGuide />', () => {
it('should render investigation guide', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: false,
basicAlertData: { ruleId: 'ruleId' },
ruleNote: 'test note',
});
const { queryByTestId } = render(renderInvestigationGuide());
expect(queryByTestId(INVESTIGATION_GUIDE_NO_DATA_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(INVESTIGATION_GUIDE_LOADING_TEST_ID)).not.toBeInTheDocument();
});
it('should render loading', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: true,
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should render no data message when there is no ruleId', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
basicAlertData: {},
ruleNote: 'test note',
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_NO_DATA_TEST_ID)).toBeInTheDocument();
});
it('should render no data message when there is no rule note', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
basicAlertData: { ruleId: 'ruleId' },
ruleNote: undefined,
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_NO_DATA_TEST_ID)).toBeInTheDocument();
});
it('should render null when dataFormattedForFieldBrowser is null', () => {
const mockContext = {
...mockContextValue,
dataFormattedForFieldBrowser: null,
};
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: false,
});
const { container } = render(renderInvestigationGuide(mockContext));
expect(container).toBeEmptyDOMElement();
});
it('should render null useInvestigationGuide errors out', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: true,
});
const { container } = render(renderInvestigationGuide());
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,80 @@
/*
* 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, EuiLink, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide';
import { useLeftPanelContext } from '../context';
import {
INVESTIGATION_GUIDE_LOADING_TEST_ID,
INVESTIGATION_GUIDE_NO_DATA_TEST_ID,
} from './test_ids';
import { InvestigationGuideView } from '../../../common/components/event_details/investigation_guide_view';
/**
* Investigation guide displayed in the left panel.
* Renders a message saying the guide hasn't been set up or the full investigation guide.
*/
export const InvestigationGuide: React.FC = () => {
const { dataFormattedForFieldBrowser } = useLeftPanelContext();
const { loading, error, basicAlertData, ruleNote } = useInvestigationGuide({
dataFormattedForFieldBrowser,
});
if (!dataFormattedForFieldBrowser || error) {
return null;
}
if (loading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj={INVESTIGATION_GUIDE_LOADING_TEST_ID}
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<>
{basicAlertData.ruleId && ruleNote ? (
<InvestigationGuideView
basicData={basicAlertData}
ruleNote={ruleNote}
showTitle={false}
showFullView={true}
/>
) : (
<div data-test-subj={INVESTIGATION_GUIDE_NO_DATA_TEST_ID}>
<FormattedMessage
id="xpack.securitySolution.flyout.investigationGuideNoData"
defaultMessage="An investigation guide has not been created for this rule. Refer to this {documentation} to learn more about adding investigation guides."
values={{
documentation: (
<EuiLink
href="https://www.elastic.co/guide/en/security/current/rules-ui-create.html#rule-ui-advanced-params"
target="_blank"
>
<FormattedMessage
id="xpack.securitySolution.flyout.documentDetails.investigationGuideDocumentationLink"
defaultMessage="documentation"
/>
</EuiLink>
),
}}
/>
</div>
)}
</>
);
};
InvestigationGuide.displayName = 'InvestigationGuide';

View file

@ -6,6 +6,7 @@
*/
/* Visualization tab */
const PREFIX = 'securitySolutionDocumentDetailsFlyout' as const;
export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const;
@ -75,3 +76,6 @@ export const CORRELATIONS_DETAILS_CASES_SECTION_TABLE_TEST_ID =
export const RESPONSE_BASE_TEST_ID = `${PREFIX}Responses` as const;
export const RESPONSE_DETAILS_TEST_ID = `${RESPONSE_BASE_TEST_ID}Details` as const;
export const RESPONSE_EMPTY_TEST_ID = `${RESPONSE_BASE_TEST_ID}Empty` as const;
export const INVESTIGATION_GUIDE_LOADING_TEST_ID = `${PREFIX}InvestigationGuideLoading`;
export const INVESTIGATION_GUIDE_NO_DATA_TEST_ID = `${PREFIX}NoData`;

View file

@ -197,27 +197,3 @@ export const CORRELATIONS_CASE_NAME_COLUMN_TITLE = i18n.translate(
defaultMessage: 'Name',
}
);
export const ANCESTRY_ALERTS_HEADING = (count: number) =>
i18n.translate('xpack.securitySolution.flyout.correlations.ancestryAlertsHeading', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}} related by ancestry',
values: { count },
});
export const SOURCE_ALERTS_HEADING = (count: number) =>
i18n.translate('xpack.securitySolution.flyout.correlations.sourceAlertsHeading', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}} related by source event',
values: { count },
});
export const SESSION_ALERTS_HEADING = (count: number) =>
i18n.translate('xpack.securitySolution.flyout.correlations.sessionAlertsHeading', {
defaultMessage: '{count, plural, one {# alert} other {# alerts}} related by session',
values: { count },
});
export const RELATED_CASES_HEADING = (count: number) =>
i18n.translate('xpack.securitySolution.flyout.correlations.relatedCasesHeading', {
defaultMessage: '{count} related {count, plural, one {case} other {cases}}',
values: { count },
});

View file

@ -7,8 +7,8 @@
import React, { memo } from 'react';
import { EuiPanel } from '@elastic/eui';
import { InvestigationGuide } from '../components/investigation_guide';
import { INVESTIGATION_TAB_CONTENT_TEST_ID } from './test_ids';
import { InvestigationGuideView } from '../../../common/components/event_details/investigation_guide_view';
import { useLeftPanelContext } from '../context';
/**
@ -16,17 +16,13 @@ import { useLeftPanelContext } from '../context';
*/
export const InvestigationTab: React.FC = memo(() => {
const { dataFormattedForFieldBrowser } = useLeftPanelContext();
if (dataFormattedForFieldBrowser === null) {
if (dataFormattedForFieldBrowser == null) {
return null;
}
return (
<EuiPanel data-test-subj={INVESTIGATION_TAB_CONTENT_TEST_ID} hasShadow={false}>
<InvestigationGuideView
data={dataFormattedForFieldBrowser}
showTitle={false}
showFullView={true}
/>
<InvestigationGuide />
</EuiPanel>
);
});

View file

@ -0,0 +1,94 @@
/*
* 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 { InvestigationGuide } from './investigation_guide';
import { RightPanelContext } from '../context';
import {
INVESTIGATION_GUIDE_BUTTON_TEST_ID,
INVESTIGATION_GUIDE_LOADING_TEST_ID,
INVESTIGATION_GUIDE_NO_DATA_TEST_ID,
} from './test_ids';
import { mockContextValue } from '../mocks/mock_right_panel_context';
import { mockFlyoutContextValue } from '../../shared/mocks/mock_flyout_context';
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide';
jest.mock('../../shared/hooks/use_investigation_guide');
const renderInvestigationGuide = (context: RightPanelContext = mockContextValue) => (
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RightPanelContext.Provider value={context}>
<InvestigationGuide />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
);
describe('<InvestigationGuide />', () => {
it('should render investigation guide button correctly', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: false,
basicAlertData: { ruleId: 'ruleId' },
ruleNote: 'test note',
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render loading', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: true,
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should render no data message when there is no ruleId', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
basicAlertData: {},
ruleNote: 'test note',
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_NO_DATA_TEST_ID)).toBeInTheDocument();
});
it('should render no data message when there is no rule note', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
basicAlertData: { ruleId: 'ruleId' },
ruleNote: undefined,
});
const { getByTestId } = render(renderInvestigationGuide());
expect(getByTestId(INVESTIGATION_GUIDE_NO_DATA_TEST_ID)).toBeInTheDocument();
});
it('should not render investigation guide button when dataFormattedForFieldBrowser is null', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: false,
show: false,
});
const mockContext = {
...mockContextValue,
dataFormattedForFieldBrowser: null,
};
const { container } = render(renderInvestigationGuide(mockContext));
expect(container).toBeEmptyDOMElement();
});
it('should render null when useInvestigationGuide errors out', () => {
(useInvestigationGuide as jest.Mock).mockReturnValue({
loading: false,
error: true,
show: false,
});
const { container } = render(renderInvestigationGuide());
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,94 @@
/*
* 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, { useCallback } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiTitle } from '@elastic/eui';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide';
import { useRightPanelContext } from '../context';
import { LeftPanelKey, LeftPanelInvestigationTab } from '../../left';
import {
INVESTIGATION_GUIDE_BUTTON_TEST_ID,
INVESTIGATION_GUIDE_LOADING_TEST_ID,
INVESTIGATION_GUIDE_NO_DATA_TEST_ID,
INVESTIGATION_GUIDE_TEST_ID,
} from './test_ids';
import {
INVESTIGATION_GUIDE_BUTTON,
INVESTIGATION_GUIDE_NO_DATA,
INVESTIGATION_GUIDE_TITLE,
} from './translations';
/**
* Render either the investigation guide button that opens Investigation section in the left panel,
* or a no-data message if investigation guide hasn't been set up on the rule
*/
export const InvestigationGuide: React.FC = () => {
const { openLeftPanel } = useExpandableFlyoutContext();
const { eventId, indexName, scopeId, dataFormattedForFieldBrowser } = useRightPanelContext();
const { loading, error, basicAlertData, ruleNote } = useInvestigationGuide({
dataFormattedForFieldBrowser,
});
const goToInvestigationsTab = useCallback(() => {
openLeftPanel({
id: LeftPanelKey,
path: {
tab: LeftPanelInvestigationTab,
},
params: {
id: eventId,
indexName,
scopeId,
},
});
}, [eventId, indexName, openLeftPanel, scopeId]);
if (!dataFormattedForFieldBrowser || error) {
return null;
}
if (loading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj={INVESTIGATION_GUIDE_LOADING_TEST_ID}
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem data-test-subj={INVESTIGATION_GUIDE_TEST_ID}>
<EuiTitle size="xxs">
<h5>{INVESTIGATION_GUIDE_TITLE}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
{basicAlertData.ruleId && ruleNote ? (
<EuiButton
onClick={goToInvestigationsTab}
iconType="documentation"
data-test-subj={INVESTIGATION_GUIDE_BUTTON_TEST_ID}
>
{INVESTIGATION_GUIDE_BUTTON}
</EuiButton>
) : (
<div data-test-subj={INVESTIGATION_GUIDE_NO_DATA_TEST_ID}>
{INVESTIGATION_GUIDE_NO_DATA}
</div>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
InvestigationGuide.displayName = 'InvestigationGuideButton';

View file

@ -1,81 +0,0 @@
/*
* 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 { InvestigationGuideButton } from './investigation_guide_button';
import { RightPanelContext } from '../context';
import { INVESTIGATION_GUIDE_BUTTON_TEST_ID } from './test_ids';
import { mockContextValue } from '../mocks/mock_right_panel_context';
import { mockFlyoutContextValue } from '../../shared/mocks/mock_flyout_context';
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
const mockUseRuleWithFallback = useRuleWithFallback as jest.Mock;
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
describe('<InvestigationGuideButton />', () => {
it('should render investigation guide button correctly', () => {
mockUseRuleWithFallback.mockReturnValue({ rule: { note: 'test note' } });
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RightPanelContext.Provider value={mockContextValue}>
<InvestigationGuideButton />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(INVESTIGATION_GUIDE_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should not render investigation guide button when dataFormattedForFieldBrowser is null', () => {
const { container } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RightPanelContext.Provider
value={{ ...mockContextValue, dataFormattedForFieldBrowser: null }}
>
<InvestigationGuideButton />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
);
expect(container).toBeEmptyDOMElement();
});
it('should not render investigation guide button when rule id is null', () => {
const { container } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RightPanelContext.Provider
value={{
...mockContextValue,
dataFormattedForFieldBrowser: [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
isObjectArray: false,
},
],
}}
>
<InvestigationGuideButton />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
);
expect(container).toBeEmptyDOMElement();
});
it('should not render investigation guide button when investigation guide is not available', () => {
mockUseRuleWithFallback.mockReturnValue({ rule: {} });
const { container } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RightPanelContext.Provider value={mockContextValue}>
<InvestigationGuideButton />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -1,55 +0,0 @@
/*
* 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, { useCallback } from 'react';
import { EuiButton } from '@elastic/eui';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useRightPanelContext } from '../context';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { LeftPanelKey, LeftPanelInvestigationTab } from '../../left';
import { INVESTIGATION_GUIDE_BUTTON_TEST_ID } from './test_ids';
import { INVESTIGATION_GUIDE_TITLE } from './translations';
/**
* Investigation guide button that opens Investigation section in the left panel
*/
export const InvestigationGuideButton: React.FC = () => {
const { openLeftPanel } = useExpandableFlyoutContext();
const { eventId, indexName, scopeId, dataFormattedForFieldBrowser } = useRightPanelContext();
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const goToInvestigationsTab = useCallback(() => {
openLeftPanel({
id: LeftPanelKey,
path: {
tab: LeftPanelInvestigationTab,
},
params: {
id: eventId,
indexName,
scopeId,
},
});
}, [eventId, indexName, openLeftPanel, scopeId]);
if (!dataFormattedForFieldBrowser || !ruleId || !maybeRule?.note) {
return null;
}
return (
<EuiButton
onClick={goToInvestigationsTab}
iconType="documentation"
data-test-subj={INVESTIGATION_GUIDE_BUTTON_TEST_ID}
>
{INVESTIGATION_GUIDE_TITLE}
</EuiButton>
);
};
InvestigationGuideButton.displayName = 'InvestigationGuideButton';

View file

@ -12,7 +12,7 @@ import { ExpandableSection } from './expandable_section';
import { HighlightedFields } from './highlighted_fields';
import { INVESTIGATION_SECTION_TEST_ID } from './test_ids';
import { INVESTIGATION_TITLE } from './translations';
import { InvestigationGuideButton } from './investigation_guide_button';
import { InvestigationGuide } from './investigation_guide';
export interface DescriptionSectionProps {
/**
* Boolean to allow the component to be expanded or collapsed on first render
@ -30,7 +30,7 @@ export const InvestigationSection: VFC<DescriptionSectionProps> = ({ expanded =
title={INVESTIGATION_TITLE}
data-test-subj={INVESTIGATION_SECTION_TEST_ID}
>
<InvestigationGuideButton />
<InvestigationGuide />
<EuiSpacer size="m" />
<HighlightedFields />
</ExpandableSection>

View file

@ -63,8 +63,11 @@ export const HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID =
export const HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID =
'securitySolutionDocumentDetailsFlyoutHighlightedFieldsAgentStatusCell';
export const INVESTIGATION_GUIDE_BUTTON_TEST_ID =
'securitySolutionDocumentDetailsFlyoutInvestigationGuideButton';
export const INVESTIGATION_GUIDE_TEST_ID =
'securitySolutionDocumentDetailsFlyoutInvestigationGuide';
export const INVESTIGATION_GUIDE_BUTTON_TEST_ID = `${INVESTIGATION_GUIDE_TEST_ID}Button`;
export const INVESTIGATION_GUIDE_LOADING_TEST_ID = `${INVESTIGATION_GUIDE_TEST_ID}Loading`;
export const INVESTIGATION_GUIDE_NO_DATA_TEST_ID = `${INVESTIGATION_GUIDE_TEST_ID}NoData`;
/* Insights section */

View file

@ -219,12 +219,26 @@ export const SHARE = i18n.translate('xpack.securitySolution.flyout.documentDetai
});
export const INVESTIGATION_GUIDE_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.investigationGuideText',
'xpack.securitySolution.flyout.documentDetails.investigationGuideTitle',
{
defaultMessage: 'Investigation guide',
}
);
export const INVESTIGATION_GUIDE_BUTTON = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.investigationGuideButton',
{
defaultMessage: 'Show investigation guide',
}
);
export const INVESTIGATION_GUIDE_NO_DATA = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.investigationGuideNoData',
{
defaultMessage: 'An investigation guide has not been created for this rule.',
}
);
export const SESSION_PREVIEW_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.sessionPreview.title',
{

View file

@ -0,0 +1,59 @@
/*
* 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 type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import type {
UseInvestigationGuideParams,
UseInvestigationGuideResult,
} from './use_investigation_guide';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useInvestigationGuide } from './use_investigation_guide';
jest.mock('../../../timelines/components/side_panel/event_details/helpers');
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
describe('useInvestigationGuide', () => {
let hookResult: RenderHookResult<UseInvestigationGuideParams, UseInvestigationGuideResult>;
it('should return loading true', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ ruleId: 'ruleId' });
(useRuleWithFallback as jest.Mock).mockReturnValue({ loading: true });
hookResult = renderHook(() => useInvestigationGuide({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toBeTruthy();
});
it('should return error true', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ ruleId: 'ruleId' });
(useRuleWithFallback as jest.Mock).mockReturnValue({ error: true });
hookResult = renderHook(() => useInvestigationGuide({ dataFormattedForFieldBrowser }));
});
it('should return basicAlertData and ruleNote', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ ruleId: 'ruleId' });
(useRuleWithFallback as jest.Mock).mockReturnValue({
loading: false,
error: false,
basicAlertsData: { ruleId: 'ruleId' },
rule: { note: 'test note' },
});
hookResult = renderHook(() => useInvestigationGuide({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.basicAlertData).toEqual({ ruleId: 'ruleId' });
expect(hookResult.result.current.ruleNote).toEqual('test note');
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { GetBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
export interface UseInvestigationGuideParams {
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
}
export interface UseInvestigationGuideResult {
/**
* True if investigation guide data is loading
*/
loading: boolean;
/**
* True if investigation guide data is in error state
*/
error: unknown;
/**
*
*/
basicAlertData: GetBasicDataFromDetailsData;
/**
*
*/
ruleNote: string | undefined;
}
/**
* Checks if the investigation guide data for a given rule is available to render
*/
export const useInvestigationGuide = ({
dataFormattedForFieldBrowser,
}: UseInvestigationGuideParams): UseInvestigationGuideResult => {
const basicAlertData = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const { loading, error, rule: maybeRule } = useRuleWithFallback(basicAlertData.ruleId);
return {
loading,
error,
basicAlertData,
ruleNote: maybeRule?.note,
};
};

View file

@ -30012,10 +30012,6 @@
"xpack.securitySolution.exceptions.viewer.lastUpdated": "Mis à jour {updated}",
"xpack.securitySolution.exceptions.viewer.paginationDetails": "Affichage de {partOne} sur {partTwo}",
"xpack.securitySolution.fieldBrowser.descriptionForScreenReaderOnly": "Description pour le champ {field} :",
"xpack.securitySolution.flyout.correlations.ancestryAlertsHeading": "{count, plural, one {# alerte} many {# alertes} other {Alertes #}} associé par ancêtre",
"xpack.securitySolution.flyout.correlations.relatedCasesHeading": "{count} associé {count, plural, one {cas} many {aux cas suivants} other {aux cas suivants}}",
"xpack.securitySolution.flyout.correlations.sessionAlertsHeading": "{count, plural, one {# alerte} many {# alertes} other {Alertes #}} associé(es) par session",
"xpack.securitySolution.flyout.correlations.sourceAlertsHeading": "{count, plural, one {# alerte} many {# alertes} other {Alertes #}} associé(es) par événement source",
"xpack.securitySolution.flyout.errorMessage": "Une erreur est survenue lors de l'affichage de {message}",
"xpack.securitySolution.flyout.errorTitle": "Impossible d'afficher {title}",
"xpack.securitySolution.footer.autoRefreshActiveTooltip": "Lorsque le rafraîchissement automatique est activé, la chronologie vous montre les {numberOfItems} derniers événements qui correspondent à votre requête.",
@ -33295,7 +33291,6 @@
"xpack.securitySolution.flyout.documentDetails.insightsOptions": "Options des informations exploitables",
"xpack.securitySolution.flyout.documentDetails.insightsTab": "Informations exploitables",
"xpack.securitySolution.flyout.documentDetails.insightsTitle": "Informations exploitables",
"xpack.securitySolution.flyout.documentDetails.investigationGuideText": "Guide d'investigation",
"xpack.securitySolution.flyout.documentDetails.investigationSectionTitle": "Investigation",
"xpack.securitySolution.flyout.documentDetails.investigationsTab": "Investigation",
"xpack.securitySolution.flyout.documentDetails.jsonTab": "JSON",

View file

@ -30011,10 +30011,6 @@
"xpack.securitySolution.exceptions.viewer.lastUpdated": "{updated}を更新しました",
"xpack.securitySolution.exceptions.viewer.paginationDetails": "{partOne}/{partTwo}ページを表示中",
"xpack.securitySolution.fieldBrowser.descriptionForScreenReaderOnly": "フィールド{field}の説明:",
"xpack.securitySolution.flyout.correlations.ancestryAlertsHeading": "上位項目に関連する{count, plural, other {#件のアラート}}",
"xpack.securitySolution.flyout.correlations.relatedCasesHeading": "{count}件の関連する{count, plural, other {ケース}}",
"xpack.securitySolution.flyout.correlations.sessionAlertsHeading": "セッションに関連する{count, plural, other {#件のアラート}}",
"xpack.securitySolution.flyout.correlations.sourceAlertsHeading": "ソースイベントに関連する{count, plural, other {#件のアラート}}",
"xpack.securitySolution.flyout.errorMessage": "{message}の表示中にエラーが発生しました",
"xpack.securitySolution.flyout.errorTitle": "{title}を表示できません",
"xpack.securitySolution.footer.autoRefreshActiveTooltip": "自動更新が有効な間、タイムラインはクエリに一致する直近{numberOfItems}件のイベントを表示します。",
@ -33294,7 +33290,6 @@
"xpack.securitySolution.flyout.documentDetails.insightsOptions": "インサイトオプション",
"xpack.securitySolution.flyout.documentDetails.insightsTab": "インサイト",
"xpack.securitySolution.flyout.documentDetails.insightsTitle": "インサイト",
"xpack.securitySolution.flyout.documentDetails.investigationGuideText": "調査ガイド",
"xpack.securitySolution.flyout.documentDetails.investigationSectionTitle": "調査",
"xpack.securitySolution.flyout.documentDetails.investigationsTab": "調査",
"xpack.securitySolution.flyout.documentDetails.jsonTab": "JSON",

View file

@ -30007,10 +30007,6 @@
"xpack.securitySolution.exceptions.viewer.lastUpdated": "已更新 {updated}",
"xpack.securitySolution.exceptions.viewer.paginationDetails": "正在显示 {partOne} 个,共 {partTwo} 个",
"xpack.securitySolution.fieldBrowser.descriptionForScreenReaderOnly": "{field} 字段的描述:",
"xpack.securitySolution.flyout.correlations.ancestryAlertsHeading": "{count, plural, other {# 个告警}}与体系相关",
"xpack.securitySolution.flyout.correlations.relatedCasesHeading": "{count} 个相关{count, plural, other {案例}}",
"xpack.securitySolution.flyout.correlations.sessionAlertsHeading": "{count, plural, other {# 个告警}}与会话相关",
"xpack.securitySolution.flyout.correlations.sourceAlertsHeading": "{count, plural, other {# 个告警}}与源事件相关",
"xpack.securitySolution.flyout.errorMessage": "显示 {message} 时出现错误",
"xpack.securitySolution.flyout.errorTitle": "无法显示 {title}",
"xpack.securitySolution.footer.autoRefreshActiveTooltip": "自动刷新已启用时,时间线将显示匹配查询的最近 {numberOfItems} 个事件。",
@ -33290,7 +33286,6 @@
"xpack.securitySolution.flyout.documentDetails.insightsOptions": "洞见选项",
"xpack.securitySolution.flyout.documentDetails.insightsTab": "洞见",
"xpack.securitySolution.flyout.documentDetails.insightsTitle": "洞见",
"xpack.securitySolution.flyout.documentDetails.investigationGuideText": "调查指南",
"xpack.securitySolution.flyout.documentDetails.investigationSectionTitle": "调查",
"xpack.securitySolution.flyout.documentDetails.investigationsTab": "调查",
"xpack.securitySolution.flyout.documentDetails.jsonTab": "JSON",

View file

@ -169,7 +169,7 @@ describe(
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INVESTIGATION_GUIDE_BUTTON)
.should('be.visible')
.and('have.text', 'Investigation guide');
.and('have.text', 'Show investigation guide');
cy.log('should navigate to left Investigation tab');