diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.test.tsx index 369aad59d5181..099490b4d0a8f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.test.tsx @@ -24,15 +24,15 @@ const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction< describe('AlertSelectionQuery', () => { const defaultProps = { - end: 'now', filterManager: jest.fn() as unknown as FilterManager, - filters: [], - query: { query: '', language: 'kuery' }, - setEnd: jest.fn(), - setFilters: jest.fn(), - setQuery: jest.fn(), - setStart: jest.fn(), - start: 'now-15m', + settings: { + end: 'now', + filters: [], + query: { query: '', language: 'kuery' }, + size: 100, + start: 'now-15m', + }, + onSettingsChanged: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx index 0cca54620a49f..742c5e5375915 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_query/index.tsx @@ -19,6 +19,7 @@ import { getCommonTimeRanges } from '../helpers/get_common_time_ranges'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { useDataView } from '../use_data_view'; +import type { AlertsSelectionSettings } from '../../types'; export const MAX_ALERTS = 500; export const MIN_ALERTS = 50; @@ -26,25 +27,15 @@ export const STEP = 50; export const NO_INDEX_PATTERNS: DataView[] = []; interface Props { - end: string; filterManager: FilterManager; - filters: Filter[]; - query: Query; - setEnd: React.Dispatch>; - setQuery: React.Dispatch>; - setStart: React.Dispatch>; - start: string; + onSettingsChanged?: (settings: AlertsSelectionSettings) => void; + settings: AlertsSelectionSettings; } const AlertSelectionQueryComponent: React.FC = ({ - end, filterManager, - filters, - query, - setEnd, - setQuery, - setStart, - start, + onSettingsChanged, + settings, }) => { const { unifiedSearch: { @@ -108,24 +99,27 @@ const AlertSelectionQueryComponent: React.FC = ({ */ const onTimeChange = useCallback( ({ start: startDate, end: endDate }: OnTimeChangeProps) => { - if (unSubmittedQuery != null) { - const newUnSubmittedQuery: Query = { - query: unSubmittedQuery, - language: 'kuery', - }; - - setQuery(newUnSubmittedQuery); // <-- set the query to the unsubmitted query - } - - setStart(startDate); - setEnd(endDate); + const query = + unSubmittedQuery != null + ? { + query: unSubmittedQuery, // <-- set the query to the unsubmitted query + language: 'kuery', + } + : settings.query; + const updatedSettings = { + ...settings, + end: endDate, + start: startDate, + query, + }; + onSettingsChanged?.(updatedSettings); }, - [setEnd, setQuery, setStart, unSubmittedQuery] + [onSettingsChanged, settings, unSubmittedQuery] ); /** * `onFiltersUpdated` is called by the `SearchBar` when the filters, (which - * appear belew the `SearchBar` input), are updated. + * appear below the `SearchBar` input), are updated. */ const onFiltersUpdated = useCallback( (newFilters: Filter[]) => { @@ -140,10 +134,13 @@ const AlertSelectionQueryComponent: React.FC = ({ const onQuerySubmit = useCallback( ({ query: newQuery }: { query?: Query | undefined }) => { if (newQuery != null) { - setQuery(newQuery); + onSettingsChanged?.({ + ...settings, + query: newQuery, + }); } }, - [setQuery] + [onSettingsChanged, settings] ); return ( @@ -160,7 +157,7 @@ const AlertSelectionQueryComponent: React.FC = ({ appName="siem" data-test-subj="alertSelectionSearchBar" indexPatterns={indexPatterns} - filters={filters} + filters={settings.filters} showDatePicker={false} showFilterBar={true} showQueryInput={true} @@ -172,7 +169,7 @@ const AlertSelectionQueryComponent: React.FC = ({ debouncedOnQueryChange(debouncedQuery?.query); }} onQuerySubmit={onQuerySubmit} - query={query} + query={settings.query} /> @@ -182,11 +179,11 @@ const AlertSelectionQueryComponent: React.FC = ({ ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx index d4d4be93a8cbf..8ea360f6465ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/alert_selection_range/index.tsx @@ -19,7 +19,7 @@ export const NO_INDEX_PATTERNS: DataView[] = []; interface Props { maxAlerts: number; - setMaxAlerts: React.Dispatch>; + setMaxAlerts: (value: string) => void; } const AlertSelectionRangeComponent: React.FC = ({ maxAlerts, setMaxAlerts }) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.test.tsx index bdb96efce2cbe..b920163977049 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.test.tsx @@ -11,13 +11,15 @@ import { ALERTS_PREVIEW, ALERT_SUMMARY } from '../../translations'; const mockProps = { alertsPreviewStackBy0: 'mockAlertsPreviewStackBy0', alertSummaryStackBy0: 'mockAlertSummaryStackBy0', - end: 'now', - filters: [], - maxAlerts: 100, - query: { query: '', language: 'kuery' }, + settings: { + end: 'now', + filters: [], + query: { query: '', language: 'kuery' }, + size: 100, + start: 'now-7', + }, setAlertsPreviewStackBy0: jest.fn(), setAlertSummaryStackBy0: jest.fn(), - start: 'now-7', }; describe('getTabs', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx index fb8ece2c877ca..a874a56268d61 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/helpers/get_tabs/index.tsx @@ -6,7 +6,6 @@ */ import { EuiSpacer } from '@elastic/eui'; -import type { Filter, Query } from '@kbn/es-query'; import React from 'react'; import { getAlertSummaryEsqlQuery } from '../../alert_summary_tab/get_alert_summary_esql_query'; @@ -16,6 +15,7 @@ import { getAlertsPreviewLensAttributes } from '../../alerts_preview_tab/get_ale import { PreviewTab } from '../../preview_tab'; import * as i18n from '../../translations'; import type { Sorting } from '../../types'; +import type { AlertsSelectionSettings } from '../../../types'; const SUMMARY_TAB_EMBEDDABLE_ID = 'alertSummaryEmbeddable--id'; const PREVIEW_TAB_EMBEDDABLE_ID = 'alertsPreviewEmbeddable--id'; @@ -42,25 +42,17 @@ export interface TabInfo { interface GetTabs { alertsPreviewStackBy0: string; alertSummaryStackBy0: string; - end: string; - filters: Filter[]; - maxAlerts: number; - query: Query; + settings: AlertsSelectionSettings; setAlertsPreviewStackBy0: React.Dispatch>; setAlertSummaryStackBy0: React.Dispatch>; - start: string; } export const getTabs = ({ alertsPreviewStackBy0, alertSummaryStackBy0, - end, - filters, - maxAlerts, - query, + settings, setAlertsPreviewStackBy0, setAlertSummaryStackBy0, - start, }: GetTabs): TabInfo[] => [ { id: 'attackDiscoverySettingsAlertSummaryTab--id', @@ -71,14 +63,14 @@ export const getTabs = ({ @@ -93,14 +85,14 @@ export const getTabs = ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx index 97f156cfc535f..93104509f97a4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.test.tsx @@ -28,18 +28,17 @@ jest.mock('../../../../sourcerer/containers'); const defaultProps = { alertsPreviewStackBy0: 'defaultAlertPreview', alertSummaryStackBy0: 'defaultAlertSummary', - end: '2024-10-01T00:00:00.000Z', filterManager: jest.fn() as unknown as FilterManager, - filters: [], - maxAlerts: 100, - query: { query: '', language: 'kuery' }, + settings: { + end: '2024-10-01T00:00:00.000Z', + filters: [], + query: { query: '', language: 'kuery' }, + size: 100, + start: '2024-09-01T00:00:00.000Z', + }, + onSettingsChanged: jest.fn(), setAlertsPreviewStackBy0: jest.fn(), setAlertSummaryStackBy0: jest.fn(), - setEnd: jest.fn(), - setMaxAlerts: jest.fn(), - setQuery: jest.fn(), - setStart: jest.fn(), - start: '2024-09-01T00:00:00.000Z', }; const mockUseKibana = useKibana as jest.MockedFunction; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx index a750593fe9552..be04aed68e4a9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/alert_selection/index.tsx @@ -7,70 +7,50 @@ import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, EuiText, EuiSpacer } from '@elastic/eui'; import type { FilterManager } from '@kbn/data-plugin/public'; -import type { Filter, Query } from '@kbn/es-query'; -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { AlertSelectionQuery } from './alert_selection_query'; import { AlertSelectionRange } from './alert_selection_range'; import { getTabs } from './helpers/get_tabs'; import * as i18n from './translations'; +import { getMaxAlerts } from './helpers/get_max_alerts'; + +import type { AlertsSelectionSettings } from '../types'; interface Props { alertsPreviewStackBy0: string; alertSummaryStackBy0: string; - end: string; filterManager: FilterManager; - filters: Filter[]; - maxAlerts: number; - query: Query; + onSettingsChanged?: (settings: AlertsSelectionSettings) => void; + settings: AlertsSelectionSettings; setAlertsPreviewStackBy0: React.Dispatch>; setAlertSummaryStackBy0: React.Dispatch>; - setEnd: React.Dispatch>; - setMaxAlerts: React.Dispatch>; - setQuery: React.Dispatch>; - setStart: React.Dispatch>; - start: string; } const AlertSelectionComponent: React.FC = ({ alertsPreviewStackBy0, alertSummaryStackBy0, - end, filterManager, - filters, - maxAlerts, - query, + onSettingsChanged, + settings, setAlertsPreviewStackBy0, setAlertSummaryStackBy0, - setEnd, - setMaxAlerts, - setQuery, - setStart, - start, }) => { const tabs = useMemo( () => getTabs({ alertsPreviewStackBy0, alertSummaryStackBy0, - end, - filters, - maxAlerts, - query, + settings, setAlertsPreviewStackBy0, setAlertSummaryStackBy0, - start, }), [ alertsPreviewStackBy0, alertSummaryStackBy0, - end, - filters, - maxAlerts, - query, + settings, setAlertsPreviewStackBy0, setAlertSummaryStackBy0, - start, ] ); @@ -81,6 +61,17 @@ const AlertSelectionComponent: React.FC = ({ [selectedTabId, tabs] ); + const onMaxAlertsChanged = useCallback( + (value: string) => { + const maxAlerts = getMaxAlerts(value); + onSettingsChanged?.({ + ...settings, + size: maxAlerts, + }); + }, + [onSettingsChanged, settings] + ); + return ( @@ -95,14 +86,9 @@ const AlertSelectionComponent: React.FC = ({ @@ -111,7 +97,7 @@ const AlertSelectionComponent: React.FC = ({ - + diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/constants.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/constants.tsx new file mode 100644 index 0000000000000..cb3128ed4b37e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/constants.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const MIN_FLYOUT_WIDTH = 448; // px diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.test.tsx index f50ab9cb912a0..3edb72427e03c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.test.tsx @@ -24,17 +24,16 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../sourcerer/containers'); const defaultProps = { - end: undefined, - filters: undefined, - localStorageAttackDiscoveryMaxAlerts: undefined, - onClose: jest.fn(), - query: undefined, - setEnd: jest.fn(), - setFilters: jest.fn(), - setLocalStorageAttackDiscoveryMaxAlerts: jest.fn(), - setQuery: jest.fn(), - setStart: jest.fn(), - start: undefined, + onSettingsReset: jest.fn(), + onSettingsSave: jest.fn(), + onSettingsChanged: jest.fn(), + settings: { + end: 'now', + filters: [], + query: { query: '', language: 'kuery' }, + size: 100, + start: 'now-15m', + }, }; const mockUseKibana = useKibana as jest.MockedFunction; @@ -69,7 +68,7 @@ describe('useSettingsView', () => { }); it('should return the alert selection component with `AlertSelectionQuery` as settings view', () => { - const { result } = renderHook(() => useSettingsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useSettingsView(defaultProps)); render({result.current.settingsView}); @@ -77,7 +76,7 @@ describe('useSettingsView', () => { }); it('should return the alert selection component with `AlertSelectionRange` as settings view', () => { - const { result } = renderHook(() => useSettingsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useSettingsView(defaultProps)); render({result.current.settingsView}); @@ -85,7 +84,7 @@ describe('useSettingsView', () => { }); it('should return reset action button', () => { - const { result } = renderHook(() => useSettingsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useSettingsView(defaultProps)); render({result.current.actionButtons}); @@ -93,45 +92,32 @@ describe('useSettingsView', () => { }); it('should return save action button', () => { - const { result } = renderHook(() => useSettingsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useSettingsView(defaultProps)); render({result.current.actionButtons}); expect(screen.getByTestId('save')).toBeInTheDocument(); }); - describe('when the save button is clicked', () => { - beforeEach(() => { - const { result } = renderHook(() => useSettingsView({ filterSettings: defaultProps })); + it('when the save button is clicked - invokes onSettingsSave', () => { + const { result } = renderHook(() => useSettingsView(defaultProps)); - render({result.current.actionButtons}); - - const save = screen.getByTestId('save'); - fireEvent.click(save); - }); + render({result.current.actionButtons}); - it('invokes setEnd', () => { - expect(defaultProps.setEnd).toHaveBeenCalled(); - }); + const save = screen.getByTestId('save'); + fireEvent.click(save); - it('invokes setFilters', () => { - expect(defaultProps.setFilters).toHaveBeenCalled(); - }); + expect(defaultProps.onSettingsSave).toHaveBeenCalled(); + }); - it('invokes setQuery', () => { - expect(defaultProps.setQuery).toHaveBeenCalled(); - }); + it('when the reset button is clicked - invokes onSettingsReset', () => { + const { result } = renderHook(() => useSettingsView(defaultProps)); - it('invokes setStart', () => { - expect(defaultProps.setStart).toHaveBeenCalled(); - }); + render({result.current.actionButtons}); - it('invokes setLocalStorageAttackDiscoveryMaxAlerts', () => { - expect(defaultProps.setLocalStorageAttackDiscoveryMaxAlerts).toHaveBeenCalled(); - }); + const reset = screen.getByTestId('reset'); + fireEvent.click(reset); - it('invokes onClose', () => { - expect(defaultProps.onClose).toHaveBeenCalled(); - }); + expect(defaultProps.onSettingsReset).toHaveBeenCalled(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.tsx index 25e5c48869c04..a5bc067cdc7c0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_settings_view.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FilterManager } from '@kbn/data-plugin/public'; -import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common'; -import type { Filter, Query } from '@kbn/es-query'; -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { AlertSelection } from '../alert_selection'; -import { DEFAULT_STACK_BY_FIELD } from '..'; -import { getDefaultQuery } from '../../helpers'; -import { getMaxAlerts } from '../alert_selection/helpers/get_max_alerts'; import * as i18n from './translations'; -import type { FilterSettings } from '../types'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { DEFAULT_STACK_BY_FIELD } from '..'; +import { AlertSelection } from '../alert_selection'; +import type { AlertsSelectionSettings } from '../types'; export interface UseSettingsView { settingsView: React.ReactNode; @@ -27,83 +23,45 @@ export interface UseSettingsView { } interface Props { - filterSettings: FilterSettings; + onSettingsReset?: () => void; + onSettingsSave?: () => void; + onSettingsChanged?: (settings: AlertsSelectionSettings) => void; + settings: AlertsSelectionSettings; } -export const useSettingsView = ({ filterSettings }: Props): UseSettingsView => { +export const useSettingsView = ({ + onSettingsReset, + onSettingsSave, + onSettingsChanged, + settings, +}: Props): UseSettingsView => { const { euiTheme } = useEuiTheme(); const { uiSettings } = useKibana().services; const filterManager = useRef(new FilterManager(uiSettings)); - const { - end, - filters, - setLocalStorageAttackDiscoveryMaxAlerts, - localStorageAttackDiscoveryMaxAlerts, - onClose, - query, - setEnd, - setFilters, - setQuery, - setStart, - start, - } = filterSettings; - const [alertSummaryStackBy0, setAlertSummaryStackBy0] = useState(DEFAULT_STACK_BY_FIELD); const [alertsPreviewStackBy0, setAlertsPreviewStackBy0] = useState(DEFAULT_STACK_BY_FIELD); - // local state: - const [localEnd, setLocalEnd] = useState(end ?? DEFAULT_END); - const [localFilters, setLocalFilters] = useState(filters ?? []); - const [localQuery, setLocalQuery] = useState(query ?? getDefaultQuery()); - const [localStart, setLocalStart] = useState(start ?? DEFAULT_START); - const [localMaxAlerts, setLocalMaxAlerts] = useState( - localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` - ); - - const onReset = useCallback(() => { - // reset local state: - setAlertSummaryStackBy0(DEFAULT_STACK_BY_FIELD); - setAlertsPreviewStackBy0(DEFAULT_STACK_BY_FIELD); - - setLocalEnd(DEFAULT_END); - setLocalFilters([]); - setLocalQuery(getDefaultQuery()); - setLocalStart(DEFAULT_START); - setLocalMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); - }, []); - - const onSave = useCallback(() => { - // copy local state: - setEnd(localEnd); - setFilters(localFilters); - setQuery(localQuery); - setStart(localStart); - setLocalStorageAttackDiscoveryMaxAlerts(localMaxAlerts); - - onClose(); - }, [ - localEnd, - localFilters, - localMaxAlerts, - localQuery, - localStart, - onClose, - setEnd, - setFilters, - setLocalStorageAttackDiscoveryMaxAlerts, - setQuery, - setStart, - ]); - - const numericMaxAlerts = useMemo(() => getMaxAlerts(localMaxAlerts), [localMaxAlerts]); + const settingsView = useMemo(() => { + return ( + + ); + }, [alertSummaryStackBy0, alertsPreviewStackBy0, onSettingsChanged, settings]); useEffect(() => { let isSubscribed = true; // init the Filter manager with the local filters: - filterManager.current.setFilters(localFilters); + filterManager.current.setFilters(settings.filters); // subscribe to filter updates: const subscription = filterManager.current.getUpdates$().subscribe({ @@ -111,7 +69,10 @@ export const useSettingsView = ({ filterSettings }: Props): UseSettingsView => { if (isSubscribed) { const newFilters = filterManager.current.getFilters(); - setLocalFilters(newFilters); + onSettingsChanged?.({ + ...settings, + filters: newFilters, + }); } }, }); @@ -120,36 +81,7 @@ export const useSettingsView = ({ filterSettings }: Props): UseSettingsView => { isSubscribed = false; subscription.unsubscribe(); }; - }, [localFilters]); - - const settingsView = useMemo(() => { - return ( - - ); - }, [ - alertSummaryStackBy0, - alertsPreviewStackBy0, - localEnd, - localFilters, - localQuery, - localStart, - numericMaxAlerts, - ]); + }, [onSettingsChanged, settings]); const actionButtons = useMemo(() => { return ( @@ -160,19 +92,19 @@ export const useSettingsView = ({ filterSettings }: Props): UseSettingsView => { `} grow={false} > - + {i18n.RESET} - + {i18n.SAVE} ); - }, [euiTheme.size.s, onReset, onSave]); + }, [euiTheme.size.s, onSettingsReset, onSettingsSave]); return { settingsView, actionButtons }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.test.tsx index 19866b8349c93..42f7a4b193adc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.test.tsx @@ -24,17 +24,16 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../sourcerer/containers'); const defaultProps = { - end: undefined, - filters: undefined, - localStorageAttackDiscoveryMaxAlerts: undefined, - onClose: jest.fn(), - query: undefined, - setEnd: jest.fn(), - setFilters: jest.fn(), - setLocalStorageAttackDiscoveryMaxAlerts: jest.fn(), - setQuery: jest.fn(), - setStart: jest.fn(), - start: undefined, + onSettingsReset: jest.fn(), + onSettingsSave: jest.fn(), + onSettingsChanged: jest.fn(), + settings: { + end: 'now', + filters: [], + query: { query: '', language: 'kuery' }, + size: 100, + start: 'now-15m', + }, }; const mockUseKibana = useKibana as jest.MockedFunction; @@ -69,7 +68,7 @@ describe('useTabsView', () => { }); it('should return the alert selection component with `AlertSelectionQuery` when settings tab is selected', () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.tabsContainer}); @@ -77,7 +76,7 @@ describe('useTabsView', () => { }); it('should return the alert selection component with `AlertSelectionRange` when settings tab is selected', () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.tabsContainer}); @@ -85,7 +84,7 @@ describe('useTabsView', () => { }); it('should return the empty schedule component with empty schedule page when schedule tab is selected', async () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.tabsContainer}); @@ -100,7 +99,7 @@ describe('useTabsView', () => { }); it('should return the empty schedule component with create new schedule button when schedule tab is selected', async () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.tabsContainer}); @@ -115,7 +114,7 @@ describe('useTabsView', () => { }); it('should return reset action button when settings tab is selected', () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.actionButtons}); @@ -123,7 +122,7 @@ describe('useTabsView', () => { }); it('should return save action button when settings tab is selected', () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.actionButtons}); @@ -131,7 +130,7 @@ describe('useTabsView', () => { }); it('should not return action buttons when schedule tab is selected', async () => { - const { result } = renderHook(() => useTabsView({ filterSettings: defaultProps })); + const { result } = renderHook(() => useTabsView(defaultProps)); render({result.current.tabsContainer}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.tsx index 34488536fdea4..7c12f6af64dcc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/hooks/use_tabs_view.tsx @@ -16,7 +16,7 @@ import { } from '@elastic/eui'; import * as i18n from './translations'; import { useSettingsView } from './use_settings_view'; -import type { FilterSettings } from '../types'; +import type { AlertsSelectionSettings } from '../types'; import { Schedule } from '../schedule'; /* @@ -36,12 +36,23 @@ export interface UseTabsView { } interface Props { - filterSettings: FilterSettings; + onSettingsReset?: () => void; + onSettingsSave?: () => void; + onSettingsChanged?: (settings: AlertsSelectionSettings) => void; + settings: AlertsSelectionSettings; } -export const useTabsView = ({ filterSettings }: Props): UseTabsView => { +export const useTabsView = ({ + onSettingsReset, + onSettingsSave, + onSettingsChanged, + settings, +}: Props): UseTabsView => { const { settingsView, actionButtons: filterActionButtons } = useSettingsView({ - filterSettings, + onSettingsReset, + onSettingsSave, + onSettingsChanged, + settings, }); const settingsTab: EuiTabbedContentTab = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.test.tsx index 28615a4e7ade8..86c0a2af603a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.test.tsx @@ -7,13 +7,16 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; +import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common'; import { SettingsFlyout } from '.'; +import { ATTACK_DISCOVERY_SETTINGS } from './translations'; +import { getDefaultQuery } from '../helpers'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useKibana } from '../../../common/lib/kibana'; import { TestProviders } from '../../../common/mock'; import { useSourcererDataView } from '../../../sourcerer/containers'; -import { ATTACK_DISCOVERY_SETTINGS } from './translations'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; jest.mock('../../../common/hooks/use_experimental_features'); jest.mock('../../../common/lib/kibana'); @@ -40,6 +43,40 @@ const defaultProps = { start: undefined, }; +const customProps = { + end: 'now-15m', + filters: [ + { + meta: { + disabled: false, + negate: false, + alias: null, + index: 'f06caf10-dfc1-4669-8f38-d11e4fcfc8af', + key: 'host.name', + field: 'host.name', + params: { + query: 'Host1', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'Host1', + }, + }, + }, + ], + localStorageAttackDiscoveryMaxAlerts: '123', + onClose: jest.fn(), + query: { query: 'user.name : "user1" ', language: 'kuery' }, + setEnd: jest.fn(), + setFilters: jest.fn(), + setLocalStorageAttackDiscoveryMaxAlerts: jest.fn(), + setQuery: jest.fn(), + setStart: jest.fn(), + start: 'now-45m', +}; + const mockUseKibana = useKibana as jest.MockedFunction; const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction< typeof useSourcererDataView @@ -141,6 +178,44 @@ describe('SettingsFlyout', () => { }); }); + describe('when the save button is clicked after the reset button is clicked', () => { + beforeEach(() => { + render( + + + + ); + + const reset = screen.getByTestId('reset'); + fireEvent.click(reset); + + const save = screen.getByTestId('save'); + fireEvent.click(save); + }); + + it('invokes setEnd with default `end` value', () => { + expect(customProps.setEnd).toHaveBeenCalledWith(DEFAULT_END); + }); + + it('invokes setFilters with default `filters` value', () => { + expect(customProps.setFilters).toHaveBeenCalledWith([]); + }); + + it('invokes setQuery with default `query` value', () => { + expect(customProps.setQuery).toHaveBeenCalledWith(getDefaultQuery()); + }); + + it('invokes setStart with default `start` value', () => { + expect(customProps.setStart).toHaveBeenCalledWith(DEFAULT_START); + }); + + it('invokes setLocalStorageAttackDiscoveryMaxAlerts with default `maxAlerts` value', () => { + expect(customProps.setLocalStorageAttackDiscoveryMaxAlerts).toHaveBeenCalledWith( + `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ); + }); + }); + describe('when `assistantAttackDiscoverySchedulingEnabled` feature flag is enabled', () => { beforeEach(() => { (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.tsx index 082a484cf3a7d..dd0e7fe50dd09 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/index.tsx @@ -14,20 +14,51 @@ import { EuiTitle, useGeneratedHtmlId, } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; +import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common'; +import type { Filter, Query } from '@kbn/es-query'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { Footer } from './footer'; import * as i18n from './translations'; import { useSettingsView } from './hooks/use_settings_view'; import { useTabsView } from './hooks/use_tabs_view'; -import type { FilterSettings } from './types'; +import type { AlertsSelectionSettings } from './types'; +import { MIN_FLYOUT_WIDTH } from './constants'; +import { getMaxAlerts } from './alert_selection/helpers/get_max_alerts'; +import { getDefaultQuery } from '../helpers'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; export const DEFAULT_STACK_BY_FIELD = 'kibana.alert.rule.name'; -const MIN_WIDTH = 448; // px - -const SettingsFlyoutComponent: React.FC = (filterSettings) => { +export interface Props { + end: string | undefined; + filters: Filter[] | undefined; + localStorageAttackDiscoveryMaxAlerts: string | undefined; + onClose: () => void; + query: Query | undefined; + setEnd: React.Dispatch>; + setFilters: React.Dispatch>; + setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch>; + setQuery: React.Dispatch>; + setStart: React.Dispatch>; + start: string | undefined; +} + +const SettingsFlyoutComponent: React.FC = ({ + end, + filters, + localStorageAttackDiscoveryMaxAlerts, + onClose, + query, + setEnd, + setFilters, + setLocalStorageAttackDiscoveryMaxAlerts, + setQuery, + setStart, + start, +}) => { const flyoutTitleId = useGeneratedHtmlId({ prefix: 'attackDiscoverySettingsFlyoutTitle', }); @@ -36,13 +67,59 @@ const SettingsFlyoutComponent: React.FC = (filterSettings) => { 'assistantAttackDiscoverySchedulingEnabled' ); - const { onClose } = filterSettings; + const [settings, setSettings] = useState({ + end: end ?? DEFAULT_END, + filters: filters ?? [], + query: query ?? getDefaultQuery(), + size: getMaxAlerts( + localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ), + start: start ?? DEFAULT_START, + }); + + const onSettingsReset = useCallback(() => { + // reset local state: + setSettings({ + end: DEFAULT_END, + filters: [], + query: getDefaultQuery(), + size: getMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`), + start: DEFAULT_START, + }); + }, []); + + const onSettingsSave = useCallback(() => { + // copy local state: + setEnd(settings.end); + setFilters(settings.filters); + setQuery(settings.query); + setStart(settings.start); + setLocalStorageAttackDiscoveryMaxAlerts(`${settings.size}`); + + onClose(); + }, [ + onClose, + setEnd, + setFilters, + setLocalStorageAttackDiscoveryMaxAlerts, + setQuery, + setStart, + settings, + ]); const { settingsView, actionButtons: settingsActionButtons } = useSettingsView({ - filterSettings, + settings, + onSettingsReset, + onSettingsSave, + onSettingsChanged: setSettings, }); - const { tabsContainer, actionButtons: tabsActionButtons } = useTabsView({ filterSettings }); + const { tabsContainer, actionButtons: tabsActionButtons } = useTabsView({ + settings, + onSettingsReset, + onSettingsSave, + onSettingsChanged: setSettings, + }); const content = useMemo(() => { if (isAttackDiscoverySchedulingEnabled) { @@ -62,7 +139,7 @@ const SettingsFlyoutComponent: React.FC = (filterSettings) => { ({ + matchPath: jest.fn(), + useLocation: jest.fn().mockReturnValue({ + search: '', + }), + withRouter: jest.fn(), +})); + +const mockUseKibana = useKibana as jest.MockedFunction; +const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction< + typeof useSourcererDataView +>; + +const defaultProps = { + onClose: jest.fn(), +}; + +describe('CreateFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseKibana.mockReturnValue({ + services: { + lens: { + EmbeddableComponent: () =>
, + }, + triggersActionsUi: { + ...triggersActionsUiMock.createStart(), + }, + uiSettings: { + get: jest.fn(), + }, + unifiedSearch: { + ui: { + SearchBar: () =>
, + }, + }, + }, + } as unknown as jest.Mocked>); + + mockUseSourcererDataView.mockReturnValue({ + sourcererDataView: {}, + loading: false, + } as unknown as jest.Mocked>); + + render( + + + + ); + }); + + it('should render the flyout title', () => { + expect(screen.getAllByTestId('title')[0]).toHaveTextContent(i18n.SCHEDULE_CREATE_TITLE); + }); + + it('should invoke onClose when the close button is clicked', async () => { + const closeButton = screen.getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + describe('schedule form', () => { + it('should render schedule form', () => { + expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument(); + }); + + it('should render schedule name field component', () => { + expect(screen.getByTestId('attackDiscoveryFormNameField')).toBeInTheDocument(); + }); + + it('should render connector selector component', () => { + expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toBeInTheDocument(); + }); + + it('should render `alertSelection` component', () => { + expect(screen.getByTestId('alertSelection')).toBeInTheDocument(); + }); + + it('should render schedule (`run every`) component', () => { + expect(screen.getByTestId('attackDiscoveryScheduleField')).toBeInTheDocument(); + }); + + it('should render actions component', () => { + expect(screen.getByText('Select a connector type')).toBeInTheDocument(); + }); + + it('should render "Create and enable" button', () => { + expect(screen.getByTestId('save')).toHaveTextContent(i18n.SCHEDULE_CREATE_BUTTON_TITLE); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/create_flyout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/create_flyout/index.tsx new file mode 100644 index 0000000000000..2f0fc38d36591 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/create_flyout/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlyoutResizable, + EuiSpacer, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import * as i18n from './translations'; + +import { Footer } from '../../footer'; +import { MIN_FLYOUT_WIDTH } from '../../constants'; +import { useEditForm } from '../hooks/use_edit_form'; +import type { AttackDiscoveryScheduleSchema } from '../hooks/types'; + +interface Props { + onClose: () => void; +} + +export const CreateFlyout: React.FC = React.memo(({ onClose }) => { + const flyoutTitleId = useGeneratedHtmlId({ + prefix: 'attackDiscoveryScheduleCreateFlyoutTitle', + }); + + const onCreateSchedule = useCallback( + (scheduleData: AttackDiscoveryScheduleSchema) => { + // TODO: handle create schedule + onClose(); + }, + [onClose] + ); + + const { editForm, actionButtons } = useEditForm({ + onSave: onCreateSchedule, + saveButtonTitle: i18n.SCHEDULE_CREATE_BUTTON_TITLE, + }); + + return ( + + + +

{i18n.SCHEDULE_CREATE_TITLE}

+
+
+ + + + {editForm} + + + +