[Security Solution] Expandable flyout - Rule preview contents (#163027)

## Summary

This PR is part 2 of adding a rule preview panel to the expandable
flyout. PR (https://github.com/elastic/kibana/pull/161999) adds the
preview skeleton, and this PR populates the actual content related to
rule details:

Expandable flyout:
- Updated title to include `created by` and `updated by` timestamps, and
rule switch button
- Added contents for about, define, schedule and actions (if any)
- Added a hook to fetch data for rule switch button - logic mimics rule
details page
(`~/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx`)

Rule & detections:
- Added `isPanelView` option allow rendering rule details in smaller
font, so that it can fit in panel view
- Minor UI updates to gutter sizes and spacing to accommodate long text
- Extracted `createdBy` and `updatedBy` to
`~/security_solution/public/detections/components/rules/rule_info` to be
shared between rule details page and flyout


![image](bbccbec6-f5f2-4ac5-8715-9caf357283ee)

**How to test**
- add `xpack.securitySolution.enableExperimental:
['securityFlyoutEnabled']` to the `kibana.dev.json` file
- go to the Alerts page, and click on the expand detail button on any
row of the table
- click on Overview, About, view Rule Summary, the rule preview panel
should pop up

### Checklist

- [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
This commit is contained in:
christineweng 2023-08-10 15:23:40 -05:00 committed by GitHub
parent 37a53b69cf
commit c28cb61fd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 572 additions and 125 deletions

View file

@ -17,13 +17,12 @@ import {
} from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import { has } from 'lodash';
import {
PREVIEW_SECTION,
PREVIEW_SECTION_BACK_BUTTON,
PREVIEW_SECTION_CLOSE_BUTTON,
PREVIEW_SECTION_HEADER,
PREVIEW_SECTION,
} from './test_ids';
import { useExpandableFlyoutContext } from '../..';
import { BACK_BUTTON, CLOSE_BUTTON } from './translations';
@ -124,28 +123,21 @@ export const PreviewSection: React.FC<PreviewSectionProps> = ({
);
return (
<>
<div
css={css`
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: ${left};
background-color: ${euiTheme.colors.shadow};
opacity: 0.5;
`}
/>
<div
css={css`
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: ${left};
z-index: 1000;
`}
>
<EuiSplitPanel.Outer
css={css`
margin: ${euiTheme.size.xs};
height: 99%;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: ${left};
z-index: 1000;
box-shadow: 0px 0px 5px 5px ${euiTheme.colors.darkShade};
`}
className="eui-yScroll"
data-test-subj={PREVIEW_SECTION}
@ -162,7 +154,7 @@ export const PreviewSection: React.FC<PreviewSectionProps> = ({
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner paddingSize="none">{component}</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</>
</div>
);
};

View file

@ -9,6 +9,9 @@ import { expandFirstAlertExpandableFlyout } from '../../../../tasks/expandable_f
import {
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_SECTION,
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_HEADER,
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_TITLE,
DOCUMENT_DETAILS_FLYOUT_CREATED_BY,
DOCUMENT_DETAILS_FLYOUT_UPDATED_BY,
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_BODY,
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_ABOUT_SECTION_HEADER,
DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_ABOUT_SECTION_CONTENT,
@ -52,10 +55,14 @@ describe(
cy.log('rule preview panel');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_SECTION).scrollIntoView();
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_SECTION).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_HEADER).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_BODY).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_FOOTER).should('be.visible');
cy.log('title');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_TITLE).scrollIntoView();
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_TITLE).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_CREATED_BY).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_UPDATED_BY).should('be.visible');
cy.log('about');
@ -84,6 +91,10 @@ describe(
.and('contain.text', 'Schedule');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_SCHEDULE_SECTION_CONTENT).should('be.visible');
toggleRulePreviewScheduleSection();
cy.log('footer');
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_FOOTER).scrollIntoView();
cy.get(DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_FOOTER).should('be.visible');
});
});
}

View file

@ -6,6 +6,9 @@
*/
import {
RULE_PREVIEW_TITLE_TEST_ID,
RULE_PREVIEW_RULE_CREATED_BY_TEST_ID,
RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID,
RULE_PREVIEW_BODY_TEST_ID,
RULE_PREVIEW_ABOUT_HEADER_TEST_ID,
RULE_PREVIEW_ABOUT_CONTENT_TEST_ID,
@ -23,6 +26,18 @@ export const DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_SECTION =
export const DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_HEADER =
getDataTestSubjectSelector('previewSectionHeader');
export const DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_TITLE = getDataTestSubjectSelector(
RULE_PREVIEW_TITLE_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_CREATED_BY = getDataTestSubjectSelector(
RULE_PREVIEW_RULE_CREATED_BY_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_UPDATED_BY = getDataTestSubjectSelector(
RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_RULE_PREVIEW_BODY =
getDataTestSubjectSelector(RULE_PREVIEW_BODY_TEST_ID);

View file

@ -130,6 +130,7 @@ export const clickInvestigationGuideButton = () => {
* Click `Rule summary` button to open rule preview panel
*/
export const clickRuleSummaryButton = () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_DESCRIPTION_TITLE).scrollIntoView();
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_DESCRIPTION_TITLE)
.should('be.visible')
.within(() => {

View file

@ -22,7 +22,6 @@ import type { Filter } from '@kbn/es-query';
import { i18n as i18nTranslate } from '@kbn/i18n';
import { Routes, Route } from '@kbn/shared-ux-router';
import { FormattedMessage } from '@kbn/i18n-react';
import { noop, omit } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
@ -53,7 +52,6 @@ import {
import { useKibana } from '../../../../common/lib/kibana';
import type { UpdateDateRange } from '../../../../common/components/charts/common';
import { FiltersGlobal } from '../../../../common/components/filters_global';
import { FormattedDate } from '../../../../common/components/formatted_date';
import {
getDetectionEngineUrl,
getRuleDetailsTabUrl,
@ -81,6 +79,7 @@ import {
getStepsData,
redirectToDetections,
} from '../../../../detections/pages/detection_engine/rules/helpers';
import { CreatedBy, UpdatedBy } from '../../../../detections/components/rules/rule_info';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { inputsSelectors } from '../../../../common/store/inputs';
import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
@ -468,33 +467,9 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
() =>
rule ? (
[
<FormattedMessage
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleCreationDescription"
defaultMessage="Created by: {by} on {date}"
values={{
by: rule?.created_by ?? i18n.UNKNOWN,
date: (
<FormattedDate
value={rule?.created_at ?? new Date().toISOString()}
fieldName="createdAt"
/>
),
}}
/>,
<CreatedBy createdBy={rule?.created_by} createdAt={rule?.created_at} />,
rule?.updated_by != null ? (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleUpdateDescription"
defaultMessage="Updated by: {by} on {date}"
values={{
by: rule?.updated_by ?? i18n.UNKNOWN,
date: (
<FormattedDate
value={rule?.updated_at ?? new Date().toISOString()}
fieldName="updatedAt"
/>
),
}}
/>
<UpdatedBy updatedBy={rule?.updated_by} updatedAt={rule?.updated_at} />
) : (
''
),

View file

@ -236,6 +236,13 @@ const OverrideColumn = styled(EuiFlexItem)`
text-overflow: ellipsis;
`;
const OverrideValueColumn = styled(EuiFlexItem)`
width: 30px;
max-width: 30px;
overflow: hidden;
text-overflow: ellipsis;
`;
export const buildSeverityDescription = (severity: AboutStepSeverity): ListItems[] => [
{
title: i18nSeverity.DEFAULT_SEVERITY,
@ -248,7 +255,7 @@ export const buildSeverityDescription = (severity: AboutStepSeverity): ListItems
return {
title: index === 0 ? i18nSeverity.SEVERITY_MAPPING : '',
description: (
<EuiFlexGroup alignItems="center">
<EuiFlexGroup alignItems="center" gutterSize="s">
<OverrideColumn>
<EuiToolTip
content={severityItem.field}
@ -257,14 +264,14 @@ export const buildSeverityDescription = (severity: AboutStepSeverity): ListItems
<>{`${severityItem.field}:`}</>
</EuiToolTip>
</OverrideColumn>
<OverrideColumn>
<OverrideValueColumn>
<EuiToolTip
content={severityItem.value}
data-test-subj={`severityOverrideValue${index}`}
>
{defaultToEmptyTag(severityItem.value)}
</EuiToolTip>
</OverrideColumn>
</OverrideValueColumn>
<EuiFlexItem grow={false}>
<EuiIcon type={'sortRight'} />
</EuiFlexItem>
@ -293,7 +300,7 @@ export const buildRiskScoreDescription = (riskScore: AboutStepRiskScore): ListIt
return {
title: index === 0 ? i18nRiskScore.RISK_SCORE_MAPPING : '',
description: (
<EuiFlexGroup alignItems="center">
<EuiFlexGroup alignItems="center" gutterSize="s">
<OverrideColumn>
<EuiToolTip
content={riskScoreItem.field}

View file

@ -9,7 +9,7 @@ import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEmpty, chunk, get, pick, isNumber } from 'lodash/fp';
import React, { memo, useState } from 'react';
import styled from 'styled-components';
import { css } from '@emotion/css';
import type { ThreatMapping, Threats, Type } from '@kbn/securitysolution-io-ts-alerting-types';
import type { DataViewBase, Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
@ -68,11 +68,19 @@ const DescriptionListContainer = styled(EuiDescriptionList)`
}
`;
const panelViewStyle = css`
dt {
font-size: 90% !important;
}
text-overflow: ellipsis;
`;
interface StepRuleDescriptionProps<T> {
columns?: 'multi' | 'single' | 'singleSplit';
data: unknown;
indexPatterns?: DataViewBase;
schema: FormSchema<T>;
isInPanelView?: boolean; // Option to show description list in smaller font
}
export const StepRuleDescriptionComponent = <T,>({
@ -80,6 +88,7 @@ export const StepRuleDescriptionComponent = <T,>({
columns = 'multi',
indexPatterns,
schema,
isInPanelView,
}: StepRuleDescriptionProps<T>) => {
const kibana = useKibana();
const license = useLicense();
@ -126,6 +135,16 @@ export const StepRuleDescriptionComponent = <T,>({
);
}
if (isInPanelView) {
return (
<EuiFlexGroup>
<EuiFlexItem data-test-subj="listItemColumnStepRuleDescriptionPanel">
<EuiDescriptionList listItems={listItems} className={panelViewStyle} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup>
<EuiFlexItem data-test-subj="listItemColumnStepRuleDescription">

View file

@ -0,0 +1,68 @@
/*
* 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 { CreatedBy, UpdatedBy } from '.';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../../common/mock';
describe('Rule related info', () => {
describe('<CreatedBy />', () => {
it('should render created correctly when by and date are passed', () => {
const { getByTestId } = render(
<TestProviders>
<CreatedBy
createdBy="test"
createdAt="2023-01-01T22:01:00.000Z"
data-test-subj="createdBy"
/>
</TestProviders>
);
expect(getByTestId('createdBy')).toHaveTextContent(
'Created by: test on Jan 1, 2023 @ 22:01:00.000'
);
});
it('should render created unknown when created by is not available', () => {
const { getByTestId } = render(
<TestProviders>
<CreatedBy createdAt="2023-01-01T22:01:00.000Z" data-test-subj="createdBy" />
</TestProviders>
);
expect(getByTestId('createdBy')).toHaveTextContent(
'Created by: Unknown on Jan 1, 2023 @ 22:01:00.000'
);
});
});
describe('<UpdatedBy />', () => {
it('should render updated by correctly when by and date are passed', () => {
const { getByTestId } = render(
<TestProviders>
<UpdatedBy
updatedBy="test"
updatedAt="2023-01-01T22:01:00.000Z"
data-test-subj="updatedBy"
/>
</TestProviders>
);
expect(getByTestId('updatedBy')).toHaveTextContent(
'Updated by: test on Jan 1, 2023 @ 22:01:00.000'
);
});
it('should render updated by correctly when updated by is not available', () => {
const { getByTestId } = render(
<TestProviders>
<UpdatedBy updatedAt="2023-01-01T22:01:00.000Z" data-test-subj="updatedBy" />
</TestProviders>
);
expect(getByTestId('updatedBy')).toHaveTextContent(
'Updated by: Unknown on Jan 1, 2023 @ 22:01:00.000'
);
});
});
});

View file

@ -0,0 +1,75 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { UNKNOWN_TEXT } from './translations';
import { FormattedDate } from '../../../../common/components/formatted_date';
interface CreatedByProps {
createdBy?: string;
createdAt?: string;
['data-test-subj']?: string;
}
/**
* Created by and created at text that are shown on rule details and rule preview in expandable flyout
*/
export const CreatedBy: React.FC<CreatedByProps> = ({
createdBy,
createdAt,
'data-test-subj': dataTestSubj,
}) => {
return (
<div data-test-subj={dataTestSubj}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleCreationDescription"
defaultMessage="Created by: {by} on {date}"
values={{
by: createdBy ?? UNKNOWN_TEXT,
date: (
<FormattedDate value={createdAt ?? new Date().toISOString()} fieldName="createdAt" />
),
}}
/>
</div>
);
};
CreatedBy.displayName = 'CreatedBy';
interface UpdatedByProps {
updatedBy?: string;
updatedAt?: string;
['data-test-subj']?: string;
}
/**
* Updated by and updated at text that are shown on rule details and rule preview in expandable flyout
*/
export const UpdatedBy: React.FC<UpdatedByProps> = ({
updatedBy,
updatedAt,
'data-test-subj': dataTestSubj,
}) => {
return (
<div data-test-subj={dataTestSubj}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleUpdateDescription"
defaultMessage="Updated by: {by} on {date}"
values={{
by: updatedBy ?? UNKNOWN_TEXT,
date: (
<FormattedDate value={updatedAt ?? new Date().toISOString()} fieldName="updatedAt" />
),
}}
/>
</div>
);
};
UpdatedBy.displayName = 'UpdatedBy';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const UNKNOWN_TEXT = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleInfo.UnknownText',
{
defaultMessage: 'Unknown',
}
);

View file

@ -57,6 +57,7 @@ interface StepAboutRuleReadOnlyProps {
addPadding: boolean;
descriptionColumns: 'multi' | 'single' | 'singleSplit';
defaultValues: AboutStepRule;
isInPanelView?: boolean; // Option to show description list in smaller font
}
const ThreeQuartersContainer = styled.div`
@ -367,10 +368,16 @@ const StepAboutRuleReadOnlyComponent: FC<StepAboutRuleReadOnlyProps> = ({
addPadding,
defaultValues: data,
descriptionColumns,
isInPanelView = false,
}) => {
return (
<StepContentWrapper data-test-subj="aboutStep" addPadding={addPadding}>
<StepRuleDescription columns={descriptionColumns} schema={defaultSchema} data={data} />
<StepRuleDescription
columns={descriptionColumns}
schema={defaultSchema}
data={data}
isInPanelView={isInPanelView}
/>
</StepContentWrapper>
);
};

View file

@ -116,6 +116,7 @@ interface StepDefineRuleReadOnlyProps {
descriptionColumns: 'multi' | 'single' | 'singleSplit';
defaultValues: DefineStepRule;
indexPattern: DataViewBase;
isInPanelView?: boolean; // Option to show description list in smaller font
}
export const MyLabelButton = styled(EuiButtonEmpty)`
@ -908,6 +909,7 @@ const StepDefineRuleReadOnlyComponent: FC<StepDefineRuleReadOnlyProps> = ({
defaultValues: data,
descriptionColumns,
indexPattern,
isInPanelView = false,
}) => {
const dataForDescription: Partial<DefineStepRule> = getStepDataDataSource(data);
@ -918,6 +920,7 @@ const StepDefineRuleReadOnlyComponent: FC<StepDefineRuleReadOnlyProps> = ({
schema={filterRuleFieldsForType(schema, data.ruleType)}
data={filterRuleFieldsForType(dataForDescription, data.ruleType)}
indexPatterns={indexPattern}
isInPanelView={isInPanelView}
/>
</StepContentWrapper>
);

View file

@ -27,6 +27,7 @@ interface StepScheduleRuleReadOnlyProps {
addPadding: boolean;
descriptionColumns: 'multi' | 'single' | 'singleSplit';
defaultValues: ScheduleStepRule;
isInPanelView?: boolean; // Option to show description list in smaller font
}
const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
@ -69,10 +70,16 @@ const StepScheduleRuleReadOnlyComponent: FC<StepScheduleRuleReadOnlyProps> = ({
addPadding,
defaultValues: data,
descriptionColumns,
isInPanelView = false,
}) => {
return (
<StepContentWrapper addPadding={addPadding}>
<StepRuleDescription columns={descriptionColumns} schema={schema} data={data} />
<StepRuleDescription
columns={descriptionColumns}
schema={schema}
data={data}
isInPanelView={isInPanelView}
/>
</StepContentWrapper>
);
};

View file

@ -12,7 +12,16 @@ import { PreviewPanelContext } from '../context';
import { mockContextValue } from '../mocks/mock_preview_panel_context';
import { mockFlyoutContextValue } from '../../shared/mocks/mock_flyout_context';
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { ThemeProvider } from 'styled-components';
import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock';
import { TestProviders } from '../../../common/mock';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { getStepsData } from '../../../detections/pages/detection_engine/rules/helpers';
import {
mockAboutStepRule,
mockDefineStepRule,
mockScheduleStepRule,
} from '../../../detection_engine/rule_management_ui/components/rules_table/__mocks__/mock';
import {
RULE_PREVIEW_BODY_TEST_ID,
RULE_PREVIEW_ABOUT_HEADER_TEST_ID,
@ -21,27 +30,57 @@ import {
RULE_PREVIEW_DEFINITION_CONTENT_TEST_ID,
RULE_PREVIEW_SCHEDULE_HEADER_TEST_ID,
RULE_PREVIEW_SCHEDULE_CONTENT_TEST_ID,
RULE_PREVIEW_ACTIONS_HEADER_TEST_ID,
RULE_PREVIEW_ACTIONS_CONTENT_TEST_ID,
RULE_PREVIEW_LOADING_TEST_ID,
} from './test_ids';
jest.mock('../../../common/lib/kibana');
const mockUseRuleWithFallback = useRuleWithFallback as jest.Mock;
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
const mockGetStepsData = getStepsData as jest.Mock;
jest.mock('../../../detections/pages/detection_engine/rules/helpers');
const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } });
const contextValue = {
...mockContextValue,
ruleId: 'rule id',
};
describe('<RulePreview />', () => {
beforeEach(() => {
// (useAppToasts as jest.Mock).mockReturnValue(useAppToastsValueMock);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render rule preview and its sub sections', () => {
mockUseRuleWithFallback.mockReturnValue({
rule: { name: 'rule name', description: 'rule description' },
});
mockGetStepsData.mockReturnValue({
aboutRuleData: mockAboutStepRule(),
defineRuleData: mockDefineStepRule(),
scheduleRuleData: mockScheduleStepRule(),
ruleActionsData: { actions: ['action'] },
});
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</ThemeProvider>
</TestProviders>
);
expect(getByTestId(RULE_PREVIEW_BODY_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_ABOUT_HEADER_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_ABOUT_CONTENT_TEST_ID)).toBeInTheDocument();
@ -49,16 +88,63 @@ describe('<RulePreview />', () => {
expect(getByTestId(RULE_PREVIEW_DEFINITION_CONTENT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_SCHEDULE_HEADER_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_SCHEDULE_CONTENT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_ACTIONS_HEADER_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_ACTIONS_CONTENT_TEST_ID)).toBeInTheDocument();
});
it('should not render actions if action is not available', () => {
mockUseRuleWithFallback.mockReturnValue({
rule: { name: 'rule name', description: 'rule description' },
});
mockGetStepsData.mockReturnValue({
aboutRuleData: mockAboutStepRule(),
defineRuleData: mockDefineStepRule(),
scheduleRuleData: mockScheduleStepRule(),
});
const { queryByTestId } = render(
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</ThemeProvider>
</TestProviders>
);
expect(queryByTestId(RULE_PREVIEW_ACTIONS_HEADER_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(RULE_PREVIEW_ACTIONS_CONTENT_TEST_ID)).not.toBeInTheDocument();
});
it('should render loading spinner when rule is loading', () => {
mockUseRuleWithFallback.mockReturnValue({ loading: true, rule: null });
const { getByTestId } = render(
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</ThemeProvider>
</TestProviders>
);
expect(getByTestId(RULE_PREVIEW_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should not render rule preview when rule is null', () => {
mockUseRuleWithFallback.mockReturnValue({});
const { queryByTestId } = render(
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<PreviewPanelContext.Provider value={contextValue}>
<RulePreview />
</PreviewPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</ThemeProvider>
</TestProviders>
);
expect(queryByTestId(RULE_PREVIEW_BODY_TEST_ID)).not.toBeInTheDocument();
});

View file

@ -4,40 +4,35 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useState, useEffect } from 'react';
import {
EuiTitle,
EuiText,
EuiHorizontalRule,
EuiSpacer,
EuiPanel,
EuiLoadingSpinner,
} from '@elastic/eui';
import { EuiText, EuiHorizontalRule, EuiSpacer, EuiPanel, EuiLoadingSpinner } from '@elastic/eui';
import type { Rule } from '../../../detection_engine/rule_management/logic';
import { usePreviewPanelContext } from '../context';
import { ExpandableSection } from '../../right/components/expandable_section';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import type { Rule } from '../../../detection_engine/rule_management/logic';
import { getStepsData } from '../../../detections/pages/detection_engine/rules/helpers';
import { RulePreviewTitle } from './rule_preview_title';
import { StepAboutRuleReadOnly } from '../../../detections/components/rules/step_about_rule';
import { StepDefineRuleReadOnly } from '../../../detections/components/rules/step_define_rule';
import { StepScheduleRuleReadOnly } from '../../../detections/components/rules/step_schedule_rule';
import { StepRuleActionsReadOnly } from '../../../detections/components/rules/step_rule_actions';
import {
RULE_PREVIEW_BODY_TEST_ID,
RULE_PREVIEW_ABOUT_TEST_ID,
RULE_PREVIEW_DEFINITION_TEST_ID,
RULE_PREVIEW_SCHEDULE_TEST_ID,
RULE_PREVIEW_ACTIONS_TEST_ID,
RULE_PREVIEW_LOADING_TEST_ID,
} from './test_ids';
import {
RULE_PREVIEW_ABOUT_TEXT,
RULE_PREVIEW_DEFINITION_TEXT,
RULE_PREVIEW_SCHEDULE_TEXT,
} from './translations';
import * as i18n from './translations';
/**
* Rule summary on a preview panel on top of the right section of expandable flyout
*/
export const RulePreview: React.FC = memo(() => {
const { ruleId } = usePreviewPanelContext();
const { ruleId, indexPattern } = usePreviewPanelContext();
const [rule, setRule] = useState<Rule | null>(null);
const { rule: maybeRule, loading } = useRuleWithFallback(ruleId ?? '');
const { rule: maybeRule, loading: ruleLoading } = useRuleWithFallback(ruleId ?? '');
// persist rule until refresh is complete
useEffect(() => {
@ -46,42 +41,88 @@ export const RulePreview: React.FC = memo(() => {
}
}, [maybeRule]);
if (loading) {
return <EuiLoadingSpinner />;
}
const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } =
rule != null
? getStepsData({ rule, detailsView: true })
: {
aboutRuleData: null,
defineRuleData: null,
scheduleRuleData: null,
ruleActionsData: null,
};
const hasNotificationActions = Boolean(ruleActionsData?.actions?.length);
const hasResponseActions = Boolean(ruleActionsData?.responseActions?.length);
const hasActions = ruleActionsData != null && (hasNotificationActions || hasResponseActions);
return rule ? (
<EuiPanel hasShadow={false} data-test-subj={RULE_PREVIEW_BODY_TEST_ID}>
<EuiTitle>
<h6>{rule.name}</h6>
</EuiTitle>
<EuiHorizontalRule />
<EuiPanel hasShadow={false} data-test-subj={RULE_PREVIEW_BODY_TEST_ID} className="eui-yScroll">
<RulePreviewTitle rule={rule} />
<EuiHorizontalRule margin="s" />
<ExpandableSection
title={RULE_PREVIEW_ABOUT_TEXT}
title={i18n.RULE_PREVIEW_ABOUT_TEXT}
expanded
data-test-subj={RULE_PREVIEW_ABOUT_TEST_ID}
>
<EuiText size="s">{rule.description}</EuiText>
<EuiSpacer size="s" />
{'About'}
</ExpandableSection>
<EuiSpacer size="m" />
<ExpandableSection
title={RULE_PREVIEW_DEFINITION_TEXT}
expanded={false}
data-test-subj={RULE_PREVIEW_DEFINITION_TEST_ID}
>
{'Definition'}
</ExpandableSection>
<EuiSpacer size="m" />
<ExpandableSection
title={RULE_PREVIEW_SCHEDULE_TEXT}
expanded={false}
data-test-subj={RULE_PREVIEW_SCHEDULE_TEST_ID}
>
{'Schedule'}
{aboutRuleData && (
<StepAboutRuleReadOnly
addPadding={false}
descriptionColumns="single"
defaultValues={aboutRuleData}
isInPanelView
/>
)}
</ExpandableSection>
<EuiHorizontalRule margin="l" />
{defineRuleData && (
<>
<ExpandableSection
title={i18n.RULE_PREVIEW_DEFINITION_TEXT}
expanded={false}
data-test-subj={RULE_PREVIEW_DEFINITION_TEST_ID}
>
<StepDefineRuleReadOnly
addPadding={false}
descriptionColumns="single"
defaultValues={defineRuleData}
indexPattern={indexPattern}
isInPanelView
/>
</ExpandableSection>
<EuiHorizontalRule margin="l" />
</>
)}
{scheduleRuleData && (
<>
<ExpandableSection
title={i18n.RULE_PREVIEW_SCHEDULE_TEXT}
expanded={false}
data-test-subj={RULE_PREVIEW_SCHEDULE_TEST_ID}
>
<StepScheduleRuleReadOnly
addPadding={false}
descriptionColumns="single"
defaultValues={scheduleRuleData}
isInPanelView
/>
</ExpandableSection>
<EuiHorizontalRule margin="l" />
</>
)}
{hasActions && (
<ExpandableSection
title={i18n.RULE_PREVIEW_ACTIONS_TEXT}
expanded={false}
data-test-subj={RULE_PREVIEW_ACTIONS_TEST_ID}
>
<StepRuleActionsReadOnly addPadding={false} defaultValues={ruleActionsData} />
</ExpandableSection>
)}
</EuiPanel>
) : ruleLoading ? (
<EuiLoadingSpinner size="l" data-test-subj={RULE_PREVIEW_LOADING_TEST_ID} />
) : null;
});

View file

@ -0,0 +1,38 @@
/*
* 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 { RulePreviewTitle } from './rule_preview_title';
import { mockFlyoutContextValue } from '../../shared/mocks/mock_flyout_context';
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { TestProviders } from '../../../common/mock';
import type { Rule } from '../../../detection_engine/rule_management/logic';
import {
RULE_PREVIEW_TITLE_TEST_ID,
RULE_PREVIEW_RULE_CREATED_BY_TEST_ID,
RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID,
} from './test_ids';
const defaultProps = {
rule: { id: 'id' } as Rule,
};
describe('<RulePreviewTitle />', () => {
it('should render title and its components', () => {
const { getByTestId } = render(
<TestProviders>
<ExpandableFlyoutContext.Provider value={mockFlyoutContextValue}>
<RulePreviewTitle {...defaultProps} />
</ExpandableFlyoutContext.Provider>
</TestProviders>
);
expect(getByTestId(RULE_PREVIEW_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_RULE_CREATED_BY_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiTitle, EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { Rule } from '../../../detection_engine/rule_management/logic';
import { CreatedBy, UpdatedBy } from '../../../detections/components/rules/rule_info';
import {
RULE_PREVIEW_TITLE_TEST_ID,
RULE_PREVIEW_RULE_CREATED_BY_TEST_ID,
RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID,
} from './test_ids';
interface RulePreviewTitleProps {
/**
* Rule object that represents relevant information about a rule
*/
rule: Rule;
}
/**
* Title component that shows basic information of a rule. This is displayed above rule preview body in rule preview panel
*/
export const RulePreviewTitle: React.FC<RulePreviewTitleProps> = ({ rule }) => {
return (
<div data-test-subj={RULE_PREVIEW_TITLE_TEST_ID}>
<EuiTitle>
<h6>{rule.name}</h6>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem data-test-subj={RULE_PREVIEW_RULE_CREATED_BY_TEST_ID}>
<EuiText size="xs">
<CreatedBy createdBy={rule?.created_by} createdAt={rule?.created_at} />
</EuiText>
</EuiFlexItem>
<EuiFlexItem data-test-subj={RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID}>
<EuiText size="xs">
<UpdatedBy updatedBy={rule?.updated_by} updatedAt={rule?.updated_at} />
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};
RulePreviewTitle.displayName = 'RulePreviewTitle';

View file

@ -9,6 +9,12 @@ import { CONTENT_TEST_ID, HEADER_TEST_ID } from '../../right/components/expandab
/* Rule preview */
export const RULE_PREVIEW_TITLE_TEST_ID = 'securitySolutionDocumentDetailsFlyoutRulePreviewTitle';
export const RULE_PREVIEW_RULE_CREATED_BY_TEST_ID =
'securitySolutionDocumentDetailsFlyoutRulePreviewCreatedByText';
export const RULE_PREVIEW_RULE_UPDATED_BY_TEST_ID =
'securitySolutionDocumentDetailsFlyoutRulePreviewUpdatedByText';
export const RULE_PREVIEW_BODY_TEST_ID = 'securitySolutionDocumentDetailsFlyoutRulePreviewBody';
export const RULE_PREVIEW_ABOUT_TEST_ID = `securitySolutionDocumentDetailsFlyoutRulePreviewAboutSection`;
export const RULE_PREVIEW_ABOUT_HEADER_TEST_ID = RULE_PREVIEW_ABOUT_TEST_ID + HEADER_TEST_ID;
@ -24,5 +30,12 @@ export const RULE_PREVIEW_SCHEDULE_TEST_ID =
export const RULE_PREVIEW_SCHEDULE_HEADER_TEST_ID = RULE_PREVIEW_SCHEDULE_TEST_ID + HEADER_TEST_ID;
export const RULE_PREVIEW_SCHEDULE_CONTENT_TEST_ID =
RULE_PREVIEW_SCHEDULE_TEST_ID + CONTENT_TEST_ID;
export const RULE_PREVIEW_ACTIONS_TEST_ID =
'securitySolutionDocumentDetailsFlyoutRulePreviewActionsSection';
export const RULE_PREVIEW_ACTIONS_HEADER_TEST_ID = RULE_PREVIEW_ACTIONS_TEST_ID + HEADER_TEST_ID;
export const RULE_PREVIEW_ACTIONS_CONTENT_TEST_ID = RULE_PREVIEW_ACTIONS_TEST_ID + CONTENT_TEST_ID;
export const RULE_PREVIEW_LOADING_TEST_ID =
'securitySolutionDocumentDetailsFlyoutRulePreviewLoadingSpinner';
export const RULE_PREVIEW_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutRulePreviewHeader';
export const RULE_PREVIEW_FOOTER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutRulePreviewFooter';
export const RULE_PREVIEW_NAVIGATE_TO_RULE_TEST_ID = 'goToRuleDetails';

View file

@ -26,3 +26,13 @@ export const RULE_PREVIEW_SCHEDULE_TEXT = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.rulePreviewScheduleSectionText',
{ defaultMessage: 'Schedule' }
);
export const RULE_PREVIEW_ACTIONS_TEXT = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.rulePreviewActionsSectionText',
{ defaultMessage: 'Actions' }
);
export const ENABLE_RULE_TEXT = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.rulePreviewEnableRuleText',
{ defaultMessage: 'Enable' }
);

View file

@ -6,7 +6,12 @@
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { DataViewBase } from '@kbn/es-query';
import type { PreviewPanelProps } from '.';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { SecurityPageName } from '../../../common/constants';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../common/containers/sourcerer';
export interface PreviewPanelContext {
/**
@ -25,6 +30,10 @@ export interface PreviewPanelContext {
* Rule id if preview is rule details
*/
ruleId: string;
/**
* Index pattern for rule details
*/
indexPattern: DataViewBase;
}
export const PreviewPanelContext = createContext<PreviewPanelContext | undefined>(undefined);
@ -43,6 +52,12 @@ export const PreviewPanelProvider = ({
ruleId,
children,
}: PreviewPanelProviderProps) => {
const [{ pageName }] = useRouteSpy();
const sourcererScope =
pageName === SecurityPageName.detections
? SourcererScopeName.detections
: SourcererScopeName.default;
const sourcererDataView = useSourcererDataView(sourcererScope);
const contextValue = useMemo(
() =>
id && indexName && scopeId
@ -51,9 +66,10 @@ export const PreviewPanelProvider = ({
indexName,
scopeId,
ruleId: ruleId ?? '',
indexPattern: sourcererDataView.indexPattern,
}
: undefined,
[id, indexName, scopeId, ruleId]
[id, indexName, scopeId, ruleId, sourcererDataView.indexPattern]
);
return (

View file

@ -6,7 +6,6 @@
*/
import React, { memo, useMemo } from 'react';
import { css } from '@emotion/react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { panels } from './panels';
@ -21,7 +20,6 @@ export interface PreviewPanelProps extends FlyoutPanelProps {
id: string;
indexName: string;
scopeId: string;
banner?: string;
ruleId?: string;
};
}
@ -38,14 +36,13 @@ export const PreviewPanel: React.FC<Partial<PreviewPanelProps>> = memo(({ path }
return null;
}
return (
<EuiFlexGroup justifyContent="spaceBetween" direction="column" className="eui-fullHeight">
<EuiFlexItem
css={css`
margin-top: -15px;
`}
>
{previewPanel.content}
</EuiFlexItem>
<EuiFlexGroup
justifyContent="spaceBetween"
direction="column"
gutterSize="none"
style={{ height: '100%' }}
>
<EuiFlexItem style={{ marginTop: '-15px' }}>{previewPanel.content}</EuiFlexItem>
<EuiFlexItem grow={false}>{previewPanel.footer}</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -15,4 +15,5 @@ export const mockContextValue: PreviewPanelContext = {
indexName: 'index',
scopeId: 'scopeId',
ruleId: '',
indexPattern: { fields: [], title: 'test index' },
};

View file

@ -27,7 +27,7 @@ export type PreviewPanelType = Array<{
/**
* Footer section in the panel
*/
footer: React.ReactElement;
footer?: React.ReactElement;
}>;
/**

View file

@ -61,7 +61,7 @@ export const Description: FC = () => {
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
iconType="arrowRight"
iconType="expand"
onClick={openRulePreview}
iconSide="right"
data-test-subj={RULE_SUMMARY_BUTTON_TEST_ID}