From e19c91333d0a1d321c1d0ff9c34d44c213462eaa Mon Sep 17 00:00:00 2001 From: christineweng Date: Wed, 4 Dec 2024 17:37:07 -0600 Subject: [PATCH] PR comments --- packages/kbn-expandable-flyout/README.md | 2 + .../kbn-expandable-flyout/src/store/state.ts | 2 +- .../right/utils/event_utils.tsx | 22 ------ .../document_details/shared/utils.test.tsx | 20 ++--- .../flyout/document_details/shared/utils.tsx | 31 +++++++- .../shared/components/flyout_history.test.tsx | 50 +------------ .../shared/components/flyout_history.tsx | 28 ------- .../components/flyout_history_row.test.tsx | 73 ++++++++++--------- .../shared/components/flyout_history_row.tsx | 69 +++++++++--------- .../shared/components/flyout_navigation.tsx | 3 +- .../flyout/shared/components/test_ids.ts | 3 + .../flyout/shared/utils/history_utils.test.ts | 56 ++++++++++++++ .../flyout/shared/utils/history_utils.ts | 31 ++++++++ 13 files changed, 206 insertions(+), 184 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts diff --git a/packages/kbn-expandable-flyout/README.md b/packages/kbn-expandable-flyout/README.md index 2bdd7ae3dfc48..930bf00334c56 100644 --- a/packages/kbn-expandable-flyout/README.md +++ b/packages/kbn-expandable-flyout/README.md @@ -61,6 +61,8 @@ To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi] > The expandable flyout propagates the `onClose` callback from the EuiFlyout component. As we recommend having a single instance of the flyout in your application, it's up to the application's code to dispatch the event (through Redux, window events, observable, prop drilling...). +When calling `openFlyout`, the right panel state is automatically appended in the `history` slice in the redux context. To access the flyout's history, you can use the [useExpandableFlyoutHistory](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts) hook. + ## Usage To use the expandable flyout in your plugin, first you need wrap your code with the [context provider](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx) at a high enough level as follows: diff --git a/packages/kbn-expandable-flyout/src/store/state.ts b/packages/kbn-expandable-flyout/src/store/state.ts index f39d324558985..46326c311fbeb 100644 --- a/packages/kbn-expandable-flyout/src/store/state.ts +++ b/packages/kbn-expandable-flyout/src/store/state.ts @@ -23,7 +23,7 @@ export interface FlyoutPanels { */ preview: FlyoutPanelProps[] | undefined; /* - * History of the right panel that were opened + * History of the right panels that were opened */ history: FlyoutPanelProps[]; } diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx index 59c06629e2a4c..5f39d73bd31f9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/event_utils.tsx @@ -48,25 +48,3 @@ export const getEcsAllowedValueDescription = (fieldName: FieldName, value: strin }) ); }; - -// mapping of event category to the field displayed as title -export const EVENT_CATEGORY_TO_FIELD: Record = { - authentication: 'user.name', - configuration: '', - database: '', - driver: '', - email: '', - file: 'file.name', - host: 'host.name', - iam: '', - intrusion_detection: '', - malware: '', - network: '', - package: '', - process: 'process.name', - registry: '', - session: '', - threat: '', - vulnerability: '', - web: '', -}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx index 10e8eaa35aaa6..6c9e9917679fd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.test.tsx @@ -6,7 +6,7 @@ */ import { getField, getFieldArray, getEventTitle, getAlertTitle } from './utils'; -describe('test getField', () => { +describe('getField', () => { it('should return the string value if field is a string', () => { expect(getField('test string')).toBe('test string'); }); @@ -29,7 +29,7 @@ describe('test getField', () => { }); }); -describe('test getFieldArray', () => { +describe('getFieldArray', () => { it('should return the string value in an array if field is a string', () => { expect(getFieldArray('test string')).toStrictEqual(['test string']); }); @@ -48,8 +48,8 @@ describe('test getFieldArray', () => { }); }); -describe('test getEventTitle', () => { - it('when event kind is event, return event title based on category', () => { +describe('getEventTitle', () => { + it('should return event title based on category when event kind is event', () => { expect( getEventTitle({ eventKind: 'event', @@ -59,31 +59,31 @@ describe('test getEventTitle', () => { ).toBe('process name'); }); - it('when event kind is alert, return External alert details', () => { + it('should return External alert details when event kind is alert', () => { expect( getEventTitle({ eventKind: 'alert', eventCategory: null, getFieldsData: jest.fn() }) ).toBe('External alert details'); }); - it('when event kind is not event or alert, return Event kind details', () => { + it('should return generic event details when event kind is not event or alert', () => { expect( getEventTitle({ eventKind: 'metric', eventCategory: null, getFieldsData: jest.fn() }) ).toBe('Metric details'); }); - it('when event kind is null, return Event details', () => { + it('should return Event details when event kind is null', () => { expect(getEventTitle({ eventKind: null, eventCategory: null, getFieldsData: jest.fn() })).toBe( 'Event details' ); }); }); -describe('test getAlertTitle', () => { - it('when ruleName is undefined, return Document details', () => { +describe('getAlertTitle', () => { + it('should return Document details when ruleName is undefined', () => { expect(getAlertTitle({ ruleName: undefined })).toBe('Document details'); }); - it('when ruleName is defined, return ruleName', () => { + it('should return ruleName when ruleName is defined', () => { expect(getAlertTitle({ ruleName: 'test rule' })).toBe('test rule'); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx index bf280f23502e5..9953fa0fbbfb1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; import { startCase } from 'lodash'; -import { EVENT_CATEGORY_TO_FIELD } from '../right/utils/event_utils'; import type { GetFieldsData } from './hooks/use_get_fields_data'; /** @@ -38,7 +37,32 @@ export const getFieldArray = (field: unknown | unknown[]) => { return []; }; -export const getAlertTitle = ({ ruleName }: { ruleName: string | undefined }) => { +// mapping of event category to the field displayed as title +export const EVENT_CATEGORY_TO_FIELD: Record = { + authentication: 'user.name', + configuration: '', + database: '', + driver: '', + email: '', + file: 'file.name', + host: 'host.name', + iam: '', + intrusion_detection: '', + malware: '', + network: '', + package: '', + process: 'process.name', + registry: '', + session: '', + threat: '', + vulnerability: '', + web: '', +}; + +/** + * Helper function to retrieve the alert title + */ +export const getAlertTitle = ({ ruleName }: { ruleName?: string | null }) => { const defaultAlertTitle = i18n.translate( 'xpack.securitySolution.flyout.right.header.headerTitle', { defaultMessage: 'Document details' } @@ -46,6 +70,9 @@ export const getAlertTitle = ({ ruleName }: { ruleName: string | undefined }) => return ruleName ?? defaultAlertTitle; }; +/** + * Helper function to retrieve the event title + */ export const getEventTitle = ({ eventKind, eventCategory, diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx index b013296eae307..ebb3d080f9053 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.test.tsx @@ -13,7 +13,7 @@ import { FLYOUT_HISTORY_BUTTON_TEST_ID, FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID, } from './test_ids'; -import { FlyoutHistory, getProcessedHistory } from './flyout_history'; +import { FlyoutHistory } from './flyout_history'; const mockedHistory = [{ id: '1' }, { id: '2' }]; @@ -48,51 +48,3 @@ describe('FlyoutHistory', () => { expect(container).toBeEmptyDOMElement(); }); }); - -describe('getProcessedHistory', () => { - const simpleHistory = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]; - const complexHistory = [ - { id: '1' }, - { id: '2' }, - { id: '1' }, - { id: '3' }, - { id: '4' }, - { id: '2' }, - ]; - - it('returns a reversed history array and removes latest entry', () => { - // input: 1, 2, 3, 4 - // reverse: 4, 3, 2, 1 - // remove latest: 4, 3, 2 - const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 5 }); - expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }, { id: '1' }]); - }); - - it('returns processed history with the maxCount', () => { - // input: 1, 2, 3, 4 - // reverse: 4, 3, 2, 1 - // remove latest: 3, 2, 1 - // keep maxCount: 3, 2 - const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 2 }); - expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }]); - }); - - it('removes duplicates and reverses', () => { - // input: 1, 2, 1, 3, 4, 2 - // reverse: 2, 4, 3, 1, 2, 1 - // remove duplicates: 2, 4, 3, 1 - // remove latest: 4, 3, 1 - const processedHistory = getProcessedHistory({ history: complexHistory, maxCount: 5 }); - expect(processedHistory).toEqual([{ id: '4' }, { id: '3' }, { id: '1' }]); - }); - - it('returns empty array if history only has one entry', () => { - const processedHistory = getProcessedHistory({ history: [{ id: '1' }], maxCount: 5 }); - expect(processedHistory).toEqual([]); - }); - - it('returns empty array if history is empty', () => { - const processedHistory = getProcessedHistory({ history: [], maxCount: 5 }); - expect(processedHistory).toEqual([]); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx index a164a991611ae..e55c40d21e9da 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history.tsx @@ -21,10 +21,6 @@ export interface HistoryProps { * A list of flyouts that have been opened */ history: FlyoutPanelProps[]; - /** - * Maximum number of flyouts to show in history - */ - maxCount?: number; } /** @@ -74,27 +70,3 @@ export const FlyoutHistory: FC = memo(({ history }) => { }); FlyoutHistory.displayName = 'FlyoutHistory'; - -/** - * Helper function that reverses the history array, - * removes duplicates and the most recent item - * @returns a history array of maxCount length - */ -export const getProcessedHistory = ({ - history, - maxCount, -}: { - history: FlyoutPanelProps[]; - maxCount: number; -}): FlyoutPanelProps[] => { - // Step 1: reverse history so the most recent is first - const reversedHistory = history.slice().reverse(); - - // Step 2: remove duplicates - const historyArray = Array.from(new Set(reversedHistory.map((i) => JSON.stringify(i)))).map((i) => - JSON.parse(i) - ); - - // Omit the first (current) entry and return array of maxCount length - return historyArray.slice(1, maxCount + 1); -}; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx index a8fbc707f7465..f3dcaefd536e0 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.test.tsx @@ -16,12 +16,8 @@ import { import { TestProviders } from '../../../common/mock'; import type { RuleResponse } from '../../../../common/api/detection_engine'; import { useExpandableFlyoutApi, type ExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback'; import { useRuleDetails } from '../../rule_details/hooks/use_rule_details'; -import { - useBasicDataFromDetailsData, - type UseBasicDataFromDetailsDataResult, -} from '../../document_details/shared/hooks/use_basic_data_from_details_data'; +import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys'; import { RulePanelKey } from '../../rule_details/right'; import { UserPanelKey } from '../../entity_details/user_right'; @@ -30,6 +26,9 @@ import { NetworkPanelKey } from '../../network_details'; import { DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID, RULE_HISTORY_ROW_TEST_ID, + HOST_HISTORY_ROW_TEST_ID, + USER_HISTORY_ROW_TEST_ID, + NETWORK_HISTORY_ROW_TEST_ID, GENERIC_HISTORY_ROW_TEST_ID, } from './test_ids'; @@ -43,6 +42,7 @@ jest.mock('@kbn/expandable-flyout', () => ({ jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback'); jest.mock('../../document_details/shared/hooks/use_basic_data_from_details_data'); jest.mock('../../rule_details/hooks/use_rule_details'); + const flyoutContextValue = { openFlyout: jest.fn(), } as unknown as ExpandableFlyoutApi; @@ -86,20 +86,18 @@ describe('FlyoutHistoryRow', () => { beforeEach(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); jest.mocked(useRuleDetails).mockReturnValue({ - rule: null, - loading: false, - isExistingRule: false, - }); - jest.mocked(useRuleWithFallback).mockReturnValue({ ...mockedRuleResponse, rule: { name: 'rule name' } as RuleResponse, }); - jest - .mocked(useBasicDataFromDetailsData) - .mockReturnValue({ isAlert: false, ruleId: 'ruleId' } as UseBasicDataFromDetailsDataResult); + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false }); }); it('renders document details history row when key is alert', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ + isAlert: true, + ruleName: 'rule name', + }); + const { getByTestId } = render( @@ -123,8 +121,8 @@ describe('FlyoutHistoryRow', () => { ); - expect(getByTestId(`${2}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); - expect(getByTestId(`${2}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Host: host name'); + expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Host: host name'); }); it('renders generic user history row when key is user', () => { @@ -133,8 +131,8 @@ describe('FlyoutHistoryRow', () => { ); - expect(getByTestId(`${3}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); - expect(getByTestId(`${3}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('User: user name'); + expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('User: user name'); }); it('renders generic network history row when key is network', () => { @@ -143,21 +141,30 @@ describe('FlyoutHistoryRow', () => { ); - expect(getByTestId(`${4}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); - expect(getByTestId(`${4}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Network: ip'); + expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument(); + expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Network: ip'); + }); + + it('renders null when key is not supported', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); }); }); describe('DocumentDetailsHistoryRow', () => { - it('renders alert title when isAlert is true and rule name is defined', () => { + beforeEach(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); - jest.mocked(useRuleWithFallback).mockReturnValue({ - ...mockedRuleResponse, - rule: { name: 'rule name' } as RuleResponse, + }); + + it('renders alert title when isAlert is true and rule name is defined', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ + isAlert: true, + ruleName: 'rule name', }); - jest - .mocked(useBasicDataFromDetailsData) - .mockReturnValue({ isAlert: true } as UseBasicDataFromDetailsDataResult); const { getByTestId } = render( @@ -170,10 +177,7 @@ describe('DocumentDetailsHistoryRow', () => { }); it('renders default alert title when isAlert is true and rule name is undefined', () => { - jest.mocked(useRuleWithFallback).mockReturnValue(mockedRuleResponse); - jest - .mocked(useBasicDataFromDetailsData) - .mockReturnValue({ isAlert: true } as UseBasicDataFromDetailsDataResult); + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true }); const { getByTestId } = render( @@ -181,15 +185,12 @@ describe('DocumentDetailsHistoryRow', () => { ); expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent( - 'Document details' + 'Alert: Document details' ); }); it('renders event title when isAlert is false', () => { - jest.mocked(useRuleWithFallback).mockReturnValue(mockedRuleResponse); - jest - .mocked(useBasicDataFromDetailsData) - .mockReturnValue({ isAlert: false } as UseBasicDataFromDetailsDataResult); + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false }); const { getByTestId } = render( @@ -202,6 +203,8 @@ describe('DocumentDetailsHistoryRow', () => { }); it('opens document details flyout when clicked', () => { + (useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true }); + const { getByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx index c6994bfc37561..1081cae88e31d 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_history_row.tsx @@ -11,7 +11,6 @@ import { EuiContextMenuItem, type EuiIconProps } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys'; -import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback'; import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; import { useEventDetails } from '../../document_details/shared/hooks/use_event_details'; import { getField, getAlertTitle, getEventTitle } from '../../document_details/shared/utils'; @@ -24,6 +23,9 @@ import { DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID, RULE_HISTORY_ROW_TEST_ID, GENERIC_HISTORY_ROW_TEST_ID, + HOST_HISTORY_ROW_TEST_ID, + USER_HISTORY_ROW_TEST_ID, + NETWORK_HISTORY_ROW_TEST_ID, } from './test_ids'; export interface FlyoutHistoryRowProps { @@ -54,6 +56,7 @@ export const FlyoutHistoryRow: FC = memo(({ item, index } title={String(item?.params?.hostName)} icon={'storage'} name={'Host'} + dataTestSubj={HOST_HISTORY_ROW_TEST_ID} /> ); case UserPanelKey: @@ -64,6 +67,7 @@ export const FlyoutHistoryRow: FC = memo(({ item, index } title={String(item?.params?.userName)} icon={'user'} name={'User'} + dataTestSubj={USER_HISTORY_ROW_TEST_ID} /> ); case NetworkPanelKey: @@ -74,48 +78,43 @@ export const FlyoutHistoryRow: FC = memo(({ item, index } title={String(item?.params?.ip)} icon={'globe'} name={'Network'} + dataTestSubj={NETWORK_HISTORY_ROW_TEST_ID} /> ); + default: + return null; } - return null; }); /** * Row item for a document details */ export const DocumentDetailsHistoryRow: FC = memo(({ item, index }) => { - const { openFlyout } = useExpandableFlyoutApi(); const { dataFormattedForFieldBrowser, getFieldsData } = useEventDetails({ eventId: String(item?.params?.id), indexName: String(item?.params?.indexName), }); - const { ruleId, isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); - const { rule: maybeRule } = useRuleWithFallback(ruleId); - const eventKind = getField(getFieldsData('event.kind')); - const eventCategory = getField(getFieldsData('event.category')); + const { ruleName, isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const eventKind = useMemo(() => getField(getFieldsData('event.kind')), [getFieldsData]); + const eventCategory = useMemo(() => getField(getFieldsData('event.category')), [getFieldsData]); const title = useMemo( () => isAlert - ? getAlertTitle({ ruleName: maybeRule?.name }) + ? getAlertTitle({ ruleName }) : getEventTitle({ eventKind, eventCategory, getFieldsData }), - [isAlert, maybeRule, eventKind, eventCategory, getFieldsData] + [isAlert, ruleName, eventKind, eventCategory, getFieldsData] ); - const onClick = useCallback(() => { - openFlyout({ right: item }); - }, [openFlyout, item]); - return ( - - {isAlert ? {'Alert: '} : {'Event: '}} - {title} - + name={isAlert ? 'Alert' : 'Event'} + dataTestSubj={DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID} + /> ); }); @@ -123,24 +122,18 @@ export const DocumentDetailsHistoryRow: FC = memo(({ item * Row item for a rule details flyout */ export const RuleHistoryRow: FC = memo(({ item, index }) => { - const { openFlyout } = useExpandableFlyoutApi(); const ruleId = String(item?.params?.ruleId); const { rule } = useRuleDetails({ ruleId }); - const onClick = useCallback(() => { - openFlyout({ right: item }); - }, [openFlyout, item]); - return ( - - {'Rule: '} - {rule?.name} - + name={'Rule'} + dataTestSubj={RULE_HISTORY_ROW_TEST_ID} + /> ); }); @@ -157,13 +150,17 @@ interface GenericHistoryRowProps extends FlyoutHistoryRowProps { * Name to display */ name: string; + /** + * Data test subject + */ + dataTestSubj?: string; } /** * Row item for a generic history row where the title is accessible in flyout params */ export const GenericHistoryRow: FC = memo( - ({ item, index, title, icon, name }) => { + ({ item, index, title, icon, name, dataTestSubj }) => { const { openFlyout } = useExpandableFlyoutApi(); const onClick = useCallback(() => { openFlyout({ right: item }); @@ -174,7 +171,7 @@ export const GenericHistoryRow: FC = memo( key={index} onClick={onClick} icon={icon} - data-test-subj={`${index}-${GENERIC_HISTORY_ROW_TEST_ID}`} + data-test-subj={`${index}-${dataTestSubj ?? GENERIC_HISTORY_ROW_TEST_ID}`} > {`${name}: `} {title} diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx index 2ea3104ff3fb0..5fb05d18bdd15 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx @@ -22,7 +22,8 @@ import { } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { FlyoutHistory, getProcessedHistory } from './flyout_history'; +import { FlyoutHistory } from './flyout_history'; +import { getProcessedHistory } from '../utils/history_utils'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { HEADER_ACTIONS_TEST_ID, diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts index f1c7b9c2f58dd..75c571ece0f50 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/test_ids.ts @@ -46,4 +46,7 @@ export const FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID = export const DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}DocumentDetailsRow` as const; export const RULE_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}RuleRow` as const; +export const HOST_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}HostRow` as const; +export const USER_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}UserRow` as const; +export const NETWORK_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}NetworkRow` as const; export const GENERIC_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}GenericRow` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts new file mode 100644 index 0000000000000..97257fa84dd8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getProcessedHistory } from './history_utils'; + +describe('getProcessedHistory', () => { + const simpleHistory = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]; + const complexHistory = [ + { id: '1' }, + { id: '2' }, + { id: '1' }, + { id: '3' }, + { id: '4' }, + { id: '2' }, + ]; + + it('returns a reversed history array and removes latest entry', () => { + // input: 1, 2, 3, 4 + // reverse: 4, 3, 2, 1 + // remove latest: 4, 3, 2 + const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 5 }); + expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }, { id: '1' }]); + }); + + it('returns processed history with the maxCount', () => { + // input: 1, 2, 3, 4 + // reverse: 4, 3, 2, 1 + // remove latest: 3, 2, 1 + // keep maxCount: 3, 2 + const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 2 }); + expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }]); + }); + + it('removes duplicates and reverses', () => { + // input: 1, 2, 1, 3, 4, 2 + // reverse: 2, 4, 3, 1, 2, 1 + // remove duplicates: 2, 4, 3, 1 + // remove latest: 4, 3, 1 + const processedHistory = getProcessedHistory({ history: complexHistory, maxCount: 5 }); + expect(processedHistory).toEqual([{ id: '4' }, { id: '3' }, { id: '1' }]); + }); + + it('returns empty array if history only has one entry', () => { + const processedHistory = getProcessedHistory({ history: [{ id: '1' }], maxCount: 5 }); + expect(processedHistory).toEqual([]); + }); + + it('returns empty array if history is empty', () => { + const processedHistory = getProcessedHistory({ history: [], maxCount: 5 }); + expect(processedHistory).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts new file mode 100644 index 0000000000000..ef31daa7f83f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/shared/utils/history_utils.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; + +/** + * Helper function that reverses the history array, + * removes duplicates and the most recent item + * @returns a history array of maxCount length + */ +export const getProcessedHistory = ({ + history, + maxCount, +}: { + history: FlyoutPanelProps[]; + maxCount: number; +}): FlyoutPanelProps[] => { + // Step 1: reverse history so the most recent is first + const reversedHistory = history.slice().reverse(); + + // Step 2: remove duplicates + const historyArray = Array.from(new Set(reversedHistory.map((i) => JSON.stringify(i)))).map((i) => + JSON.parse(i) + ); + + // Omit the first (current) entry and return array of maxCount length + return historyArray.slice(1, maxCount + 1); +};