mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740)](https://github.com/elastic/kibana/pull/216740) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"christineweng","email":"18648970+christineweng@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-04-15T17:18:35Z","message":"[Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted fields in alert flyout, under\n`Investigations`. The modal shows default highlighted fields that are\ndefined by Elastic, and allow user to edit custom highlighted fields.\n\nCurrently this feature is behind feature flag\n`editHighlightedFieldsEnabled` (not enabled by default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n#### Disabled when:\n\n<details>\n<summary>User does not have security privilege</summary>\n\n\n\n\n</details>\n\n<details>\n<summary>Prebuilt rule w/o enterprise license (showing upsell)</summary>\n\n\n\n\n</details>\n\n#### Do not show the button when:\n<details>\n<summary>Not an alert </summary>\n\n\n\n\n</details>\n\n<details>\n<summary>rule preview</summary>\n\n\n\n\n</details>\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers should verify this PR satisfies this list as well.\n\n- [x] Any text added follows [EUI's writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\nsentence case text and includes [i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:feature","Team:Threat Hunting:Investigations","backport:version","v9.1.0","v8.19.0"],"title":"[Security Solution][Alert flyout] Edit highlighted fields in overview tab","number":216740,"url":"https://github.com/elastic/kibana/pull/216740","mergeCommit":{"message":"[Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted fields in alert flyout, under\n`Investigations`. The modal shows default highlighted fields that are\ndefined by Elastic, and allow user to edit custom highlighted fields.\n\nCurrently this feature is behind feature flag\n`editHighlightedFieldsEnabled` (not enabled by default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n#### Disabled when:\n\n<details>\n<summary>User does not have security privilege</summary>\n\n\n\n\n</details>\n\n<details>\n<summary>Prebuilt rule w/o enterprise license (showing upsell)</summary>\n\n\n\n\n</details>\n\n#### Do not show the button when:\n<details>\n<summary>Not an alert </summary>\n\n\n\n\n</details>\n\n<details>\n<summary>rule preview</summary>\n\n\n\n\n</details>\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers should verify this PR satisfies this list as well.\n\n- [x] Any text added follows [EUI's writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\nsentence case text and includes [i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/216740","number":216740,"mergeCommit":{"message":"[Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted fields in alert flyout, under\n`Investigations`. The modal shows default highlighted fields that are\ndefined by Elastic, and allow user to edit custom highlighted fields.\n\nCurrently this feature is behind feature flag\n`editHighlightedFieldsEnabled` (not enabled by default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n#### Disabled when:\n\n<details>\n<summary>User does not have security privilege</summary>\n\n\n\n\n</details>\n\n<details>\n<summary>Prebuilt rule w/o enterprise license (showing upsell)</summary>\n\n\n\n\n</details>\n\n#### Do not show the button when:\n<details>\n<summary>Not an alert </summary>\n\n\n\n\n</details>\n\n<details>\n<summary>rule preview</summary>\n\n\n\n\n</details>\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers should verify this PR satisfies this list as well.\n\n- [x] Any text added follows [EUI's writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing), uses\nsentence case text and includes [i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: christineweng <18648970+christineweng@users.noreply.github.com>
This commit is contained in:
parent
e5bb57656c
commit
3e77aaab8f
18 changed files with 1167 additions and 73 deletions
|
@ -235,6 +235,11 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
*/
|
||||
newExpandableFlyoutNavigationDisabled: false,
|
||||
|
||||
/**
|
||||
* Enables the ability to edit highlighted fields in the alertflyout
|
||||
*/
|
||||
editHighlightedFieldsEnabled: false,
|
||||
|
||||
/**
|
||||
* Enables CrowdStrike's RunScript RTR command
|
||||
* Release: 8.18/9.0
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 { alwaysDisplayedFields, getHighlightedFieldsToDisplay } from './get_alert_summary_rows';
|
||||
|
||||
describe('getHighlightedFieldsToDisplay', () => {
|
||||
it('should return custom highlighted fields correctly', () => {
|
||||
const result = getHighlightedFieldsToDisplay({
|
||||
eventCategories: {},
|
||||
ruleCustomHighlightedFields: ['customField1', 'customField2'],
|
||||
type: 'custom',
|
||||
});
|
||||
expect(result).toEqual([{ id: 'customField1' }, { id: 'customField2' }]);
|
||||
});
|
||||
|
||||
it('should return the default highlighted fields correctly', () => {
|
||||
const result = getHighlightedFieldsToDisplay({
|
||||
eventCategories: {},
|
||||
ruleCustomHighlightedFields: ['customField1', 'customField2'],
|
||||
type: 'default',
|
||||
});
|
||||
expect(result).toEqual(alwaysDisplayedFields);
|
||||
});
|
||||
|
||||
it('should return both custom and default highlighted fields correctly', () => {
|
||||
const result = getHighlightedFieldsToDisplay({
|
||||
eventCategories: {},
|
||||
ruleCustomHighlightedFields: ['customField1', 'customField2'],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ id: 'customField1' },
|
||||
{ id: 'customField2' },
|
||||
...alwaysDisplayedFields,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return a list of unique fields', () => {
|
||||
const result = getHighlightedFieldsToDisplay({
|
||||
eventCategories: {},
|
||||
ruleCustomHighlightedFields: ['customField1', 'customField2', 'host.name'],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ id: 'customField1' },
|
||||
{ id: 'customField2' },
|
||||
...alwaysDisplayedFields,
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -49,7 +49,7 @@ const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alerts.ruleT
|
|||
});
|
||||
|
||||
/** Always show these fields */
|
||||
const alwaysDisplayedFields: EventSummaryField[] = [
|
||||
export const alwaysDisplayedFields: EventSummaryField[] = [
|
||||
{ id: 'host.name' },
|
||||
|
||||
// Add all fields used to identify the agent ID in alert events and override them to
|
||||
|
@ -68,8 +68,6 @@ const alwaysDisplayedFields: EventSummaryField[] = [
|
|||
{ id: 'rule.name' },
|
||||
{ id: 'cloud.provider' },
|
||||
{ id: 'cloud.region' },
|
||||
{ id: 'cloud.provider' },
|
||||
{ id: 'cloud.region' },
|
||||
{ id: 'orchestrator.cluster.id' },
|
||||
{ id: 'orchestrator.cluster.name' },
|
||||
{ id: 'container.image.name' },
|
||||
|
@ -239,7 +237,7 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] {
|
|||
* @param customs The list of custom-defined fields to display
|
||||
* @returns The list of custom-defined fields to display
|
||||
*/
|
||||
function getHighlightedFieldsOverride(customs: string[]): EventSummaryField[] {
|
||||
function getCustomHighlightedFields(customs: string[]): EventSummaryField[] {
|
||||
return customs.map((field) => ({ id: field }));
|
||||
}
|
||||
|
||||
|
@ -253,27 +251,36 @@ function getHighlightedFieldsOverride(customs: string[]): EventSummaryField[] {
|
|||
/**
|
||||
* Assembles a list of fields to display based on the event
|
||||
*/
|
||||
export function getEventFieldsToDisplay({
|
||||
export function getHighlightedFieldsToDisplay({
|
||||
eventCategories,
|
||||
eventCode,
|
||||
eventRuleType,
|
||||
highlightedFieldsOverride,
|
||||
ruleCustomHighlightedFields,
|
||||
type = 'all',
|
||||
}: {
|
||||
eventCategories: EventCategories;
|
||||
eventCode?: string;
|
||||
eventRuleType?: string;
|
||||
highlightedFieldsOverride: string[];
|
||||
ruleCustomHighlightedFields: string[];
|
||||
type?: 'default' | 'custom' | 'all';
|
||||
}): EventSummaryField[] {
|
||||
const fields = [
|
||||
...getHighlightedFieldsOverride(highlightedFieldsOverride),
|
||||
const customHighlightedFields = getCustomHighlightedFields(ruleCustomHighlightedFields);
|
||||
const defaultHighlightedFields = [
|
||||
...alwaysDisplayedFields,
|
||||
...getFieldsByCategory(eventCategories),
|
||||
...getFieldsByEventCode(eventCode, eventCategories),
|
||||
...getFieldsByRuleType(eventRuleType),
|
||||
];
|
||||
|
||||
// Filter all fields by their id to make sure there are no duplicates
|
||||
return uniqBy('id', fields);
|
||||
if (type === 'default') {
|
||||
return uniqBy('id', defaultHighlightedFields);
|
||||
}
|
||||
|
||||
if (type === 'custom') {
|
||||
return customHighlightedFields;
|
||||
}
|
||||
|
||||
return uniqBy('id', [...customHighlightedFields, ...defaultHighlightedFields]);
|
||||
}
|
||||
|
||||
interface EventCategories {
|
||||
|
|
|
@ -43,7 +43,7 @@ import { removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-ho
|
|||
|
||||
import type { EcsSecurityExtension as Ecs, CodeSignature } from '@kbn/securitysolution-ecs';
|
||||
import type { EventSummaryField } from '../../../common/components/event_details/types';
|
||||
import { getEventFieldsToDisplay } from '../../../common/components/event_details/get_alert_summary_rows';
|
||||
import { getHighlightedFieldsToDisplay } from '../../../common/components/event_details/get_alert_summary_rows';
|
||||
import * as i18n from './translations';
|
||||
import type { AlertData, Flattened } from './types';
|
||||
|
||||
|
@ -987,11 +987,11 @@ export const getAlertHighlightedFields = (
|
|||
allEventCategories: Array.isArray(eventCategory) ? eventCategory : [eventCategory],
|
||||
};
|
||||
|
||||
const fieldsToDisplay = getEventFieldsToDisplay({
|
||||
const fieldsToDisplay = getHighlightedFieldsToDisplay({
|
||||
eventCategories,
|
||||
eventCode,
|
||||
eventRuleType,
|
||||
highlightedFieldsOverride: ruleCustomHighlightedFields,
|
||||
ruleCustomHighlightedFields,
|
||||
});
|
||||
return filterHighlightedFields(fieldsToDisplay, highlightedFieldsPrefixToExclude, alertData);
|
||||
};
|
||||
|
|
|
@ -8,15 +8,33 @@
|
|||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_DETAILS_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_TITLE_TEST_ID,
|
||||
} from './test_ids';
|
||||
import { HighlightedFields } from './highlighted_fields';
|
||||
import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback';
|
||||
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
|
||||
import { mockContextValue } from '../../shared/mocks/mock_context';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields');
|
||||
jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fallback');
|
||||
jest.mock('../../../../detection_engine/rule_creation_ui/pages/form');
|
||||
jest.mock('../../../../common/hooks/use_experimental_features');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields_privilege');
|
||||
jest.mock('../../../rule_details/hooks/use_rule_details');
|
||||
const mockAddSuccess = jest.fn();
|
||||
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
|
||||
useAppToasts: () => ({
|
||||
addSuccess: mockAddSuccess,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderHighlightedFields = (contextValue: DocumentDetailsContext) =>
|
||||
render(
|
||||
|
@ -30,35 +48,97 @@ const renderHighlightedFields = (contextValue: DocumentDetailsContext) =>
|
|||
const NO_DATA_MESSAGE = "There's no highlighted fields for this alert.";
|
||||
|
||||
describe('<HighlightedFields />', () => {
|
||||
beforeEach(() => {
|
||||
(useRuleWithFallback as jest.Mock).mockReturnValue({ investigation_fields: undefined });
|
||||
});
|
||||
|
||||
it('should render the component', () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
|
||||
scopeId: 'scopeId',
|
||||
} as unknown as DocumentDetailsContext;
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
describe('when editHighlightedFieldsEnabled is false', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: ['field'] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
const { getByTestId } = renderHighlightedFields(contextValue);
|
||||
it('should render the component', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
const { getByTestId, queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { getByText, queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
|
||||
scopeId: 'scopeId',
|
||||
} as unknown as DocumentDetailsContext;
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
describe('when editHighlightedFieldsEnabled is true', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: ['field'] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: { id: '123' } as RuleResponse,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
const { getByText } = renderHighlightedFields(contextValue);
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
it('should render the component', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = renderHighlightedFields(mockContextValue);
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { getByText, getByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit button if rule is null', () => {
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
const { queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,18 +6,18 @@
|
|||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiPanel, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { convertHighlightedFieldsToTableRow } from '../../shared/utils/highlighted_fields_helpers';
|
||||
import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback';
|
||||
import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data';
|
||||
import { HighlightedFieldsCell } from './highlighted_fields_cell';
|
||||
import { CellActions } from '../../shared/components/cell_actions';
|
||||
import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids';
|
||||
import { useDocumentDetailsContext } from '../../shared/context';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import { EditHighlightedFieldsButton } from './highlighted_fields_button';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
|
||||
export interface HighlightedFieldsTableRow {
|
||||
/**
|
||||
|
@ -92,13 +92,17 @@ const columns: Array<EuiBasicTableColumn<HighlightedFieldsTableRow>> = [
|
|||
* Component that displays the highlighted fields in the right panel under the Investigation section.
|
||||
*/
|
||||
export const HighlightedFields: FC = () => {
|
||||
const { dataFormattedForFieldBrowser, scopeId, isPreview } = useDocumentDetailsContext();
|
||||
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
|
||||
const { loading, rule: maybeRule } = useRuleWithFallback(ruleId);
|
||||
const { dataFormattedForFieldBrowser, scopeId, isPreview, investigationFields } =
|
||||
useDocumentDetailsContext();
|
||||
|
||||
const [isEditLoading, setIsEditLoading] = useState(false);
|
||||
const editHighlightedFieldsEnabled = useIsExperimentalFeatureEnabled(
|
||||
'editHighlightedFieldsEnabled'
|
||||
);
|
||||
|
||||
const highlightedFields = useHighlightedFields({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields: maybeRule?.investigation_fields?.field_names ?? [],
|
||||
investigationFields,
|
||||
});
|
||||
const items = useMemo(
|
||||
() => convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview),
|
||||
|
@ -106,16 +110,29 @@ export const HighlightedFields: FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
|
||||
defaultMessage="Highlighted fields"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup alignItems="center" css={{ minHeight: '40px' }}>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
|
||||
defaultMessage="Highlighted fields"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{editHighlightedFieldsEnabled && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EditHighlightedFieldsButton
|
||||
customHighlightedFields={investigationFields}
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
setIsEditLoading={setIsEditLoading}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_DETAILS_TEST_ID}>
|
||||
<EuiPanel hasBorder hasShadow={false}>
|
||||
|
@ -123,7 +140,7 @@ export const HighlightedFields: FC = () => {
|
|||
items={items}
|
||||
columns={columns}
|
||||
compressed
|
||||
loading={loading}
|
||||
loading={isEditLoading}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.noDataDescription"
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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, fireEvent } from '@testing-library/react';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { EditHighlightedFieldsButton } from './highlighted_fields_button';
|
||||
import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID,
|
||||
} from './test_ids';
|
||||
import { mockContextValue } from '../../shared/mocks/mock_context';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
|
||||
jest.mock(
|
||||
'../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'
|
||||
);
|
||||
jest.mock('../../../../detection_engine/rule_creation_ui/pages/form');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields_privilege');
|
||||
jest.mock('../../../rule_details/hooks/use_rule_details');
|
||||
|
||||
const mockSetIsEditLoading = jest.fn();
|
||||
const mockCustomHighlightedFields = ['field1', 'field2'];
|
||||
const defaultProps = {
|
||||
rule: { id: '123', index: ['index1', 'index2'] } as RuleResponse,
|
||||
customHighlightedFields: mockCustomHighlightedFields,
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
|
||||
setIsEditLoading: mockSetIsEditLoading,
|
||||
isExistingRule: true,
|
||||
};
|
||||
|
||||
const renderEditHighlighedFieldsButton = (props = defaultProps) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={mockContextValue}>
|
||||
<EditHighlightedFieldsButton {...props} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
describe('<EditHighlighedFieldsButton />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: [{ name: 'field1' }, { name: 'field2' }] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: { id: '123' } as RuleResponse,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render button when user has privilege to edit rule', () => {
|
||||
const { getByTestId } = renderEditHighlighedFieldsButton();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render disabled button when user does not have privilege to edit a prebuilt rule', () => {
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isDisabled: true,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
const { getByTestId } = renderEditHighlighedFieldsButton();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render modal when button is clicked', () => {
|
||||
const { getByTestId } = renderEditHighlighedFieldsButton();
|
||||
|
||||
const button = getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID);
|
||||
fireEvent.click(button);
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading spinner when rule is loading', () => {
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: true,
|
||||
});
|
||||
const { getByTestId } = renderEditHighlighedFieldsButton();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render button when rule is not found', () => {
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: false,
|
||||
loading: false,
|
||||
});
|
||||
const { container } = renderEditHighlighedFieldsButton();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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, { useState, useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { EuiButtonEmpty, EuiToolTip, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data';
|
||||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
import { HighlightedFieldsModal } from './highlighted_fields_modal';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
interface EditHighlightedFieldsButtonProps {
|
||||
/**
|
||||
* Preselected custom highlighted fields
|
||||
*/
|
||||
customHighlightedFields: string[];
|
||||
/**
|
||||
* The data formatted for field browser
|
||||
*/
|
||||
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[];
|
||||
/**
|
||||
* The function to set the edit loading state
|
||||
*/
|
||||
setIsEditLoading: (isEditLoading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays the highlighted fields in the right panel under the Investigation section.
|
||||
*/
|
||||
export const EditHighlightedFieldsButton: FC<EditHighlightedFieldsButtonProps> = ({
|
||||
customHighlightedFields,
|
||||
dataFormattedForFieldBrowser,
|
||||
setIsEditLoading,
|
||||
}) => {
|
||||
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
|
||||
const { rule, isExistingRule, loading: isRuleLoading } = useRuleDetails({ ruleId });
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const onClick = useCallback(() => setIsModalVisible(true), []);
|
||||
|
||||
const { isDisabled, tooltipContent } = useHighlightedFieldsPrivilege({
|
||||
rule,
|
||||
isExistingRule,
|
||||
});
|
||||
|
||||
if (isRuleLoading) {
|
||||
return (
|
||||
<EuiLoadingSpinner size="m" data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiToolTip
|
||||
content={tooltipContent}
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
iconType={'gear'}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButton"
|
||||
defaultMessage="Edit"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiToolTip>
|
||||
{isModalVisible && (
|
||||
<HighlightedFieldsModal
|
||||
customHighlightedFields={customHighlightedFields}
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
rule={rule}
|
||||
setIsEditLoading={setIsEditLoading}
|
||||
setIsModalVisible={setIsModalVisible}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* 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 { fireEvent, render, act } from '@testing-library/react';
|
||||
import type { DataViewFieldBase } from '@kbn/es-query';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { mockContextValue } from '../../shared/mocks/mock_context';
|
||||
import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message';
|
||||
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
|
||||
import { HighlightedFieldsModal } from './highlighted_fields_modal';
|
||||
import type { RuleResponse, RuleUpdateProps } from '../../../../../common/api/detection_engine';
|
||||
import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID,
|
||||
} from './test_ids';
|
||||
import { useUpdateRule } from '../../../../detection_engine/rule_management/logic/use_update_rule';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
|
||||
jest.mock(
|
||||
'../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'
|
||||
);
|
||||
jest.mock('../../../../detection_engine/rule_creation_ui/pages/form');
|
||||
jest.mock('../../../../detection_engine/rule_management/logic/use_update_rule');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields');
|
||||
jest.mock('../../../rule_details/hooks/use_rule_details');
|
||||
|
||||
const mockAddSuccess = jest.fn();
|
||||
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
|
||||
useAppToasts: () => ({
|
||||
addSuccess: mockAddSuccess,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetIsEditLoading = jest.fn();
|
||||
const mockSetIsModalVisible = jest.fn();
|
||||
const mockFieldOptions = [{ name: 'field1' }, { name: 'field2' }] as DataViewFieldBase[];
|
||||
const mockUpdateRule = jest.fn();
|
||||
const mockRule = { id: '123', name: 'test rule' } as RuleResponse;
|
||||
|
||||
const defaultProps = {
|
||||
rule: mockRule,
|
||||
customHighlightedFields: [] as string[],
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
|
||||
setIsEditLoading: mockSetIsEditLoading,
|
||||
setIsModalVisible: mockSetIsModalVisible,
|
||||
};
|
||||
|
||||
const renderHighlighedFieldsModal = (props = defaultProps) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={mockContextValue}>
|
||||
<HighlightedFieldsModal {...props} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
describe('<HighlighedFieldsModal />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue('upsell message');
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: [{ name: 'option1' }, { name: 'option2' }] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useUpdateRule as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn(),
|
||||
});
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
default1: { values: ['test'] },
|
||||
default2: { values: ['test2'] },
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: mockFieldOptions },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render modal without preselected custom fields', async () => {
|
||||
const { getByTestId, queryByTestId } = renderHighlighedFieldsModal();
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
const fields = getByTestId(HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID);
|
||||
for (const f of ['default1', 'default2']) {
|
||||
expect(fields).toHaveTextContent(f);
|
||||
}
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId('euiComboBoxPill')).not.toBeInTheDocument(); // no preselected custom fields
|
||||
});
|
||||
|
||||
it('should render modal with preselectedcustom fields', () => {
|
||||
const { getByTestId, getAllByTestId } = renderHighlighedFieldsModal({
|
||||
...defaultProps,
|
||||
customHighlightedFields: ['custom1', 'custom2'],
|
||||
});
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID)).toBeInTheDocument();
|
||||
expect(getAllByTestId('euiComboBoxPill')).toHaveLength(2);
|
||||
expect(getAllByTestId('euiComboBoxPill')[0]).toHaveTextContent('custom1');
|
||||
expect(getAllByTestId('euiComboBoxPill')[1]).toHaveTextContent('custom2');
|
||||
});
|
||||
|
||||
it('should close modal when cancel button is clicked', async () => {
|
||||
const { getByTestId } = renderHighlighedFieldsModal();
|
||||
const cancelButton = getByTestId(HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID);
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelButton);
|
||||
});
|
||||
expect(mockSetIsModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should update rule when save button is clicked', async () => {
|
||||
(useUpdateRule as jest.Mock).mockReturnValue({ mutateAsync: mockUpdateRule });
|
||||
mockUpdateRule.mockResolvedValue({
|
||||
name: 'updated rule',
|
||||
} as RuleResponse);
|
||||
|
||||
const { getByTestId } = renderHighlighedFieldsModal({
|
||||
...defaultProps,
|
||||
customHighlightedFields: ['custom1', 'custom2'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getByTestId(HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID).click();
|
||||
});
|
||||
|
||||
expect(mockUpdateRule).toHaveBeenCalledWith({
|
||||
name: mockRule.name,
|
||||
investigation_fields: { field_names: ['custom1', 'custom2'] },
|
||||
} as RuleUpdateProps);
|
||||
|
||||
expect(mockAddSuccess).toHaveBeenCalledWith('updated rule was saved');
|
||||
expect(mockUpdateRule).toHaveBeenCalled();
|
||||
expect(mockSetIsEditLoading).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetIsModalVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,287 @@
|
|||
/*
|
||||
* 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, { useMemo, useState, useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiSpacer,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
useGeneratedHtmlId,
|
||||
EuiBadge,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import type { RuleResponse, RuleUpdateProps } from '../../../../../common/api/detection_engine';
|
||||
import { getDefineStepsData } from '../../../../detection_engine/common/helpers';
|
||||
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
|
||||
import { useDefaultIndexPattern } from '../../../../detection_engine/rule_management/hooks/use_default_index_pattern';
|
||||
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
|
||||
import { useUpdateRule } from '../../../../detection_engine/rule_management/logic/use_update_rule';
|
||||
import {
|
||||
Form,
|
||||
Field,
|
||||
getUseField,
|
||||
useForm,
|
||||
FIELD_TYPES,
|
||||
fieldValidators,
|
||||
} from '../../../../shared_imports';
|
||||
import type { FormSchema } from '../../../../shared_imports';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_MODAL_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
const SUCCESSFULLY_SAVED_RULE = (ruleName: string) =>
|
||||
i18n.translate('xpack.securitySolution.detectionEngine.rules.update.successfullySavedRuleTitle', {
|
||||
values: { ruleName },
|
||||
defaultMessage: '{ruleName} was saved',
|
||||
});
|
||||
|
||||
const ADD_CUSTOM_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.investigation.highlightedFields.modalAddCustomFieldLabel',
|
||||
{ defaultMessage: 'Add custom' }
|
||||
);
|
||||
|
||||
const SELECT_PLACEHOLDER = i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.investigation.highlightedFields.modalSelectPlaceholder',
|
||||
{ defaultMessage: 'Select or search for options' }
|
||||
);
|
||||
|
||||
const CommonUseField = getUseField({ component: Field });
|
||||
|
||||
interface InvestigationFieldsFormData {
|
||||
customHighlightedFields: string[];
|
||||
}
|
||||
|
||||
const schema: FormSchema<InvestigationFieldsFormData> = {
|
||||
customHighlightedFields: {
|
||||
fieldsToValidateOnChange: ['customHighlightedFields'],
|
||||
type: FIELD_TYPES.COMBO_BOX,
|
||||
validations: [{ validator: fieldValidators.emptyField('error') }],
|
||||
},
|
||||
};
|
||||
|
||||
const formConfig = {
|
||||
...schema.customHighlightedFields,
|
||||
label: ADD_CUSTOM_FIELD_LABEL,
|
||||
};
|
||||
|
||||
interface HighlightedFieldsModalProps {
|
||||
/**
|
||||
* The data formatted for field browser
|
||||
*/
|
||||
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[];
|
||||
/**
|
||||
* The rule
|
||||
*/
|
||||
rule: RuleResponse;
|
||||
/**
|
||||
* The custom highlighted fields
|
||||
*/
|
||||
customHighlightedFields: string[];
|
||||
/**
|
||||
* The function to set the edit loading state
|
||||
*/
|
||||
setIsEditLoading: (isEditLoading: boolean) => void;
|
||||
/**
|
||||
* The function to set the modal visible state
|
||||
*/
|
||||
setIsModalVisible: (isModalVisible: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for editing the highlighted fields of a rule.
|
||||
*/
|
||||
export const HighlightedFieldsModal: FC<HighlightedFieldsModalProps> = ({
|
||||
rule,
|
||||
customHighlightedFields,
|
||||
dataFormattedForFieldBrowser,
|
||||
setIsEditLoading,
|
||||
setIsModalVisible,
|
||||
}) => {
|
||||
const defaultIndexPattern = useDefaultIndexPattern();
|
||||
const { dataSourceType, index, dataViewId } = useMemo(() => getDefineStepsData(rule), [rule]);
|
||||
const { indexPattern: dataView } = useRuleIndexPattern({
|
||||
dataSourceType,
|
||||
index: index.length > 0 ? index : defaultIndexPattern, // fallback to default index pattern if rule has no index patterns
|
||||
dataViewId,
|
||||
});
|
||||
|
||||
const { addSuccess } = useAppToasts();
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { mutateAsync: updateRule } = useUpdateRule();
|
||||
const modalTitleId = useGeneratedHtmlId();
|
||||
|
||||
const defaultFields = useHighlightedFields({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields: customHighlightedFields,
|
||||
type: 'default',
|
||||
});
|
||||
const defaultFieldsArray = useMemo(() => Object.keys(defaultFields), [defaultFields]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const allFields = dataView.fields;
|
||||
return allFields
|
||||
.filter((field) => !defaultFieldsArray.includes(field.name))
|
||||
.map((field) => ({ label: field.name }));
|
||||
}, [dataView, defaultFieldsArray]);
|
||||
|
||||
const customFields = useMemo(
|
||||
() => customHighlightedFields.map((field: string) => ({ label: field })),
|
||||
[customHighlightedFields]
|
||||
);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState(customFields);
|
||||
|
||||
const { form } = useForm({
|
||||
defaultValue: { customHighlightedFields: [] },
|
||||
schema,
|
||||
});
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setIsModalVisible(false);
|
||||
}, [setIsModalVisible]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
setIsEditLoading(true);
|
||||
|
||||
const updatedRule = await updateRule({
|
||||
...rule,
|
||||
id: undefined,
|
||||
investigation_fields:
|
||||
selectedOptions.length > 0
|
||||
? { field_names: selectedOptions.map((option) => option.label) }
|
||||
: undefined,
|
||||
} as RuleUpdateProps);
|
||||
|
||||
addSuccess(SUCCESSFULLY_SAVED_RULE(updatedRule?.name ?? 'rule'));
|
||||
setIsEditLoading(false);
|
||||
setIsModalVisible(false);
|
||||
}, [updateRule, addSuccess, rule, setIsModalVisible, setIsEditLoading, selectedOptions]);
|
||||
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
idAria: 'customizeHighlightedFields',
|
||||
'data-test-subj': HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID,
|
||||
euiFieldProps: {
|
||||
fullWidth: true,
|
||||
noSuggestions: false,
|
||||
onChange: (fields: Array<{ label: string }>) => setSelectedOptions(fields),
|
||||
options,
|
||||
placeholder: SELECT_PLACEHOLDER,
|
||||
selectedOptions,
|
||||
},
|
||||
}),
|
||||
[options, selectedOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiModal
|
||||
css={{ width: '600px' }}
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_TEST_ID}
|
||||
id={modalTitleId}
|
||||
onClose={onCancel}
|
||||
>
|
||||
<EuiModalHeader data-test-subj={HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID}>
|
||||
<EuiModalHeaderTitle id={modalTitleId} size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalTitle"
|
||||
defaultMessage="Edit highlighted fields"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<EuiText size="s" data-test-subj={HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalDescription"
|
||||
defaultMessage="Changes made here will be applied to the {ruleName} rule. Any custom fields you add will be displayed in all alerts generated by this rule."
|
||||
values={{ ruleName: <b>{rule.name}</b> }}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText
|
||||
size="xs"
|
||||
css={css`
|
||||
font-weight: ${euiTheme.font.weight.semiBold};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalDefaultFieldsTitle"
|
||||
defaultMessage="Default"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID}
|
||||
gutterSize="xs"
|
||||
justifyContent="flexStart"
|
||||
wrap
|
||||
>
|
||||
{defaultFieldsArray.map((field: string) => (
|
||||
<EuiFlexItem key={field} grow={false}>
|
||||
<EuiBadge color="hollow">{field}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="l" />
|
||||
<Form form={form}>
|
||||
<CommonUseField
|
||||
path="customHighlightedFields"
|
||||
config={formConfig}
|
||||
componentProps={componentProps}
|
||||
/>
|
||||
</Form>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID}
|
||||
flush="left"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalCancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID}
|
||||
fill
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalSaveButton"
|
||||
defaultMessage="Save"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
};
|
|
@ -23,11 +23,20 @@ import { mockContextValue } from '../../shared/mocks/mock_context';
|
|||
import { useExpandSection } from '../hooks/use_expand_section';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useFetchIndex } from '../../../../common/containers/source';
|
||||
|
||||
jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fallback');
|
||||
jest.mock('../hooks/use_expand_section');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields');
|
||||
jest.mock('../../../../common/hooks/use_experimental_features');
|
||||
jest.mock('../../../../common/containers/source');
|
||||
|
||||
const mockAddSuccess = jest.fn();
|
||||
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
|
||||
useAppToasts: () => ({
|
||||
addSuccess: mockAddSuccess,
|
||||
}),
|
||||
}));
|
||||
|
||||
const panelContextValue = {
|
||||
...mockContextValue,
|
||||
|
@ -52,6 +61,7 @@ describe('<InvestigationSection />', () => {
|
|||
jest.clearAllMocks();
|
||||
(useRuleWithFallback as jest.Mock).mockReturnValue({ rule: { note: 'test note' } });
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
(useFetchIndex as jest.Mock).mockReturnValue([false, { indexPatterns: { fields: ['field'] } }]);
|
||||
});
|
||||
|
||||
it('should render investigation component', () => {
|
||||
|
|
|
@ -101,6 +101,26 @@ export const HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID =
|
|||
export const HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_TEST_ID}AgentStatusCell` as const;
|
||||
|
||||
export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_TEST_ID}EditButton` as const;
|
||||
export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}Loading` as const;
|
||||
export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}Tooltip` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_TEST_ID = `${HIGHLIGHTED_FIELDS_TEST_ID}Modal` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}Title` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}Description` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}DefaultFields` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}CustomFields` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}SaveButton` as const;
|
||||
export const HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID =
|
||||
`${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}CancelButton` as const;
|
||||
|
||||
/* Insights section */
|
||||
|
||||
export const INSIGHTS_TEST_ID = `${PREFIX}Insights` as const;
|
||||
|
|
|
@ -138,19 +138,19 @@ export const DocumentDetailsProvider = memo(
|
|||
}
|
||||
: undefined,
|
||||
[
|
||||
id,
|
||||
indexName,
|
||||
scopeId,
|
||||
browserFields,
|
||||
dataAsNestedObject,
|
||||
dataFormattedForFieldBrowser,
|
||||
searchHit,
|
||||
browserFields,
|
||||
maybeRule?.investigation_fields?.field_names,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
id,
|
||||
indexName,
|
||||
isPreviewMode,
|
||||
jumpToEntityId,
|
||||
jumpToCursor,
|
||||
jumpToEntityId,
|
||||
refetchFlyoutData,
|
||||
scopeId,
|
||||
searchHit,
|
||||
maybeRule,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -21,7 +21,12 @@ const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
|
|||
|
||||
describe('useHighlightedFields', () => {
|
||||
it('should return data', () => {
|
||||
const hookResult = renderHook(() => useHighlightedFields({ dataFormattedForFieldBrowser }));
|
||||
const hookResult = renderHook(() =>
|
||||
useHighlightedFields({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields: [],
|
||||
})
|
||||
);
|
||||
expect(hookResult.result.current).toEqual({
|
||||
'host.name': {
|
||||
values: ['host-name'],
|
||||
|
@ -39,6 +44,7 @@ describe('useHighlightedFields', () => {
|
|||
const hookResult = renderHook(() =>
|
||||
useHighlightedFields({
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowserWithOverridenField,
|
||||
investigationFields: [],
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { useAlertResponseActionsSupport } from '../../../../common/hooks/endpoin
|
|||
import { isResponseActionsAlertAgentIdField } from '../../../../common/lib/endpoint';
|
||||
import {
|
||||
getEventCategoriesFromData,
|
||||
getEventFieldsToDisplay,
|
||||
getHighlightedFieldsToDisplay,
|
||||
} from '../../../../common/components/event_details/get_alert_summary_rows';
|
||||
|
||||
export interface UseHighlightedFieldsParams {
|
||||
|
@ -23,7 +23,14 @@ export interface UseHighlightedFieldsParams {
|
|||
/**
|
||||
* An array of fields user has selected to highlight, defined on rule
|
||||
*/
|
||||
investigationFields?: string[];
|
||||
investigationFields: string[];
|
||||
/**
|
||||
* Optional prop to specify the type of highlighted fields to display
|
||||
* Custom: fields defined on the rule
|
||||
* Default: fields defined by elastic
|
||||
* All: both custom and default fields
|
||||
*/
|
||||
type?: 'default' | 'custom' | 'all';
|
||||
}
|
||||
|
||||
export interface UseHighlightedFieldsResult {
|
||||
|
@ -45,6 +52,7 @@ export interface UseHighlightedFieldsResult {
|
|||
export const useHighlightedFields = ({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields,
|
||||
type,
|
||||
}: UseHighlightedFieldsParams): UseHighlightedFieldsResult => {
|
||||
const responseActionsSupport = useAlertResponseActionsSupport(dataFormattedForFieldBrowser);
|
||||
const eventCategories = getEventCategoriesFromData(dataFormattedForFieldBrowser);
|
||||
|
@ -67,11 +75,12 @@ export const useHighlightedFields = ({
|
|||
? eventRuleTypeField?.originalValue?.[0]
|
||||
: eventRuleTypeField?.originalValue;
|
||||
|
||||
const tableFields = getEventFieldsToDisplay({
|
||||
const tableFields = getHighlightedFieldsToDisplay({
|
||||
eventCategories,
|
||||
eventCode,
|
||||
eventRuleType,
|
||||
highlightedFieldsOverride: investigationFields ?? [],
|
||||
ruleCustomHighlightedFields: investigationFields,
|
||||
type,
|
||||
});
|
||||
|
||||
return tableFields.reduce<UseHighlightedFieldsResult>((acc, field) => {
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* 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 { renderHook } from '@testing-library/react';
|
||||
import { useHighlightedFieldsPrivilege } from './use_highlighted_fields_privilege';
|
||||
import type { UseHighlightedFieldsPrivilegeParams } from './use_highlighted_fields_privilege';
|
||||
import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message';
|
||||
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
|
||||
import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
import {
|
||||
LACK_OF_KIBANA_SECURITY_PRIVILEGES,
|
||||
ML_RULES_DISABLED_MESSAGE,
|
||||
} from '../../../../detection_engine/common/translations';
|
||||
import { useUserData } from '../../../../detections/components/user_info';
|
||||
|
||||
jest.mock('../../../../common/components/ml/hooks/use_ml_capabilities');
|
||||
jest.mock(
|
||||
'../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'
|
||||
);
|
||||
jest.mock('../../../../../common/machine_learning/has_ml_license');
|
||||
jest.mock('../../../../../common/machine_learning/has_ml_admin_permissions');
|
||||
jest.mock('../../../../detections/components/user_info');
|
||||
|
||||
const defaultProps = {
|
||||
rule: {} as RuleResponse,
|
||||
isExistingRule: true,
|
||||
};
|
||||
|
||||
const renderUseHighlightedFieldsPrivilege = (props: UseHighlightedFieldsPrivilegeParams) =>
|
||||
renderHook(() => useHighlightedFieldsPrivilege(props));
|
||||
|
||||
describe('useHighlightedFieldsPrivilege', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useUserData as jest.Mock).mockReturnValue([{ canUserCRUD: true }]);
|
||||
(hasMlAdminPermissions as jest.Mock).mockReturnValue(false);
|
||||
(hasMlLicense as jest.Mock).mockReturnValue(false);
|
||||
(usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('should return isDisabled as true when rule is null', () => {
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: null,
|
||||
});
|
||||
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toBe('Deleted rule cannot be edited.');
|
||||
});
|
||||
|
||||
it('should return isDisabled as true when rule does not exist', () => {
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
isExistingRule: false,
|
||||
});
|
||||
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toBe('Deleted rule cannot be edited.');
|
||||
});
|
||||
|
||||
it('should return isDisabled as true when user does not have CRUD privileges', () => {
|
||||
(useUserData as jest.Mock).mockReturnValue([{ canUserCRUD: false }]);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege(defaultProps);
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toContain(LACK_OF_KIBANA_SECURITY_PRIVILEGES);
|
||||
});
|
||||
|
||||
describe('when rule is machine learning rule', () => {
|
||||
it('should return isDisabled as true when user does not have ml permissions', () => {
|
||||
(hasMlAdminPermissions as jest.Mock).mockReturnValue(false);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'machine_learning' } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toContain(ML_RULES_DISABLED_MESSAGE);
|
||||
});
|
||||
|
||||
it('should return isDisabled as true when user does not have ml license', () => {
|
||||
(hasMlLicense as jest.Mock).mockReturnValue(false);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'machine_learning' } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toContain(ML_RULES_DISABLED_MESSAGE);
|
||||
});
|
||||
|
||||
it('should return isDisabled as false when user has ml permissions and proper license', () => {
|
||||
(hasMlAdminPermissions as jest.Mock).mockReturnValue(true);
|
||||
(hasMlLicense as jest.Mock).mockReturnValue(true);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'machine_learning' } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(false);
|
||||
expect(result.current.tooltipContent).toBe('Edit highlighted fields');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when rule is not machine learning rule', () => {
|
||||
it('should return isDisabled as false when rule is not immutable (custom rule)', () => {
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'query', immutable: false } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(false);
|
||||
expect(result.current.tooltipContent).toBe('Edit highlighted fields');
|
||||
});
|
||||
|
||||
it('should return isDisabled as false when rule is immutable (prebuilt rule) and upselling message is undefined', () => {
|
||||
(usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue(undefined);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'query', immutable: true } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(false);
|
||||
expect(result.current.tooltipContent).toContain('Edit highlighted fields');
|
||||
});
|
||||
|
||||
it('should return isDisabled as true when rule is immutable (prebuilt rule) and upselling message is available', () => {
|
||||
(usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue(
|
||||
'upselling message'
|
||||
);
|
||||
const { result } = renderUseHighlightedFieldsPrivilege({
|
||||
...defaultProps,
|
||||
rule: { type: 'query', immutable: true } as RuleResponse,
|
||||
});
|
||||
expect(result.current.isDisabled).toBe(true);
|
||||
expect(result.current.tooltipContent).toContain('upselling message');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
|
||||
import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message';
|
||||
import {
|
||||
explainLackOfPermission,
|
||||
hasUserCRUDPermission,
|
||||
} from '../../../../common/utils/privileges';
|
||||
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
|
||||
import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions';
|
||||
import { useUserData } from '../../../../detections/components/user_info';
|
||||
import { isMlRule } from '../../../../../common/machine_learning/helpers';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
|
||||
export interface UseHighlightedFieldsPrivilegeParams {
|
||||
/**
|
||||
* The rule to be edited
|
||||
*/
|
||||
rule: RuleResponse | null;
|
||||
/**
|
||||
* Whether the rule exists
|
||||
*/
|
||||
isExistingRule: boolean;
|
||||
}
|
||||
|
||||
interface UseHighlightedFieldsPrivilegeResult {
|
||||
/**
|
||||
* Whether edit highlighted fields button is disabled
|
||||
*/
|
||||
isDisabled: boolean;
|
||||
/**
|
||||
* The tooltip content
|
||||
*/
|
||||
tooltipContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the edit highlighted fields button is disabled and the tooltip content
|
||||
*/
|
||||
export const useHighlightedFieldsPrivilege = ({
|
||||
rule,
|
||||
isExistingRule,
|
||||
}: UseHighlightedFieldsPrivilegeParams): UseHighlightedFieldsPrivilegeResult => {
|
||||
const [{ canUserCRUD }] = useUserData();
|
||||
const mlCapabilities = useMlCapabilities();
|
||||
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
|
||||
|
||||
const isEditRuleDisabled =
|
||||
!rule ||
|
||||
!isExistingRule ||
|
||||
!hasUserCRUDPermission(canUserCRUD) ||
|
||||
(isMlRule(rule?.type) && !hasMlPermissions);
|
||||
|
||||
const upsellingMessage = usePrebuiltRuleCustomizationUpsellingMessage(
|
||||
'prebuilt_rule_customization'
|
||||
);
|
||||
|
||||
const isDisabled = isEditRuleDisabled || (Boolean(upsellingMessage) && rule?.immutable);
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
const explanation = explainLackOfPermission(
|
||||
rule,
|
||||
hasMlPermissions,
|
||||
true, // default true because we don't need the message for lack of action privileges
|
||||
canUserCRUD
|
||||
);
|
||||
|
||||
if (isEditRuleDisabled && explanation) {
|
||||
return explanation;
|
||||
}
|
||||
if (isEditRuleDisabled && (!isExistingRule || !rule)) {
|
||||
return i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsDeletedRuleTooltip',
|
||||
{ defaultMessage: 'Deleted rule cannot be edited.' }
|
||||
);
|
||||
}
|
||||
if (upsellingMessage && rule?.immutable) {
|
||||
return i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButtonUpsellingTooltip',
|
||||
{
|
||||
defaultMessage: '{upsellingMessage}',
|
||||
values: { upsellingMessage },
|
||||
}
|
||||
);
|
||||
}
|
||||
return i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButtonTooltip',
|
||||
{ defaultMessage: 'Edit highlighted fields' }
|
||||
);
|
||||
}, [canUserCRUD, hasMlPermissions, isEditRuleDisabled, isExistingRule, rule, upsellingMessage]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isDisabled,
|
||||
tooltipContent,
|
||||
}),
|
||||
[isDisabled, tooltipContent]
|
||||
);
|
||||
};
|
|
@ -41,7 +41,7 @@ export interface UsePrevalenceParams {
|
|||
/**
|
||||
* User defined fields to highlight (defined on the rule)
|
||||
*/
|
||||
investigationFields?: string[];
|
||||
investigationFields: string[];
|
||||
}
|
||||
|
||||
export interface UsePrevalenceResult {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue