diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index 05eeb2b000a3f..1cfbc59eed308 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -19,7 +19,7 @@ import { useCasesToast } from '../../../common/use_cases_toast'; import { alertComment } from '../../../containers/mock'; import { useCreateAttachments } from '../../../containers/use_create_attachments'; import { CasesContext } from '../../cases_context'; -import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { CasesContextStoreActionsList } from '../../cases_context/state/cases_context_reducer'; import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry'; import type { AddToExistingCaseModalProps } from './use_cases_add_to_existing_case_modal'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 0fa30647b60ac..e9d37452e6ea7 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -11,7 +11,7 @@ import { CaseStatuses } from '../../../../common/types/domain'; import type { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; import type { CaseUI } from '../../../containers/types'; -import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { CasesContextStoreActionsList } from '../../cases_context/state/cases_context_reducer'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; import type { CaseAttachmentsWithoutOwner } from '../../../types'; diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx index 40793010c789f..be21f3dc8f37b 100644 --- a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx @@ -10,7 +10,7 @@ import { getAllCasesSelectorModalNoProviderLazy } from '../../client/ui/get_all_ import { getCreateCaseFlyoutLazyNoProvider } from '../../client/ui/get_create_case_flyout'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; -import { getInitialCasesContextState } from './cases_context_reducer'; +import { getInitialCasesContextState } from './state/cases_context_reducer'; import { CasesGlobalComponents } from './cases_global_components'; jest.mock('../../client/ui/get_create_case_flyout'); diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx index 2add2322b1f0b..54dc933113960 100644 --- a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { getAllCasesSelectorModalNoProviderLazy } from '../../client/ui/get_all_cases_selector_modal'; import { getCreateCaseFlyoutLazyNoProvider } from '../../client/ui/get_create_case_flyout'; -import type { CasesContextState } from './cases_context_reducer'; +import type { CasesContextState } from './state/cases_context_reducer'; export const CasesGlobalComponents = React.memo(({ state }: { state: CasesContextState }) => { return ( diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index a59a76f8adb6a..85c267f5d05d7 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -15,7 +15,6 @@ import { FilesContext } from '@kbn/shared-ux-file-context'; import type { QueryClient } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query'; -import type { CasesContextStoreAction } from './cases_context_reducer'; import type { CasesFeaturesAllRequired, CasesFeatures, @@ -29,7 +28,9 @@ import { CasesGlobalComponents } from './cases_global_components'; import { DEFAULT_FEATURES } from '../../../common/constants'; import { constructFileKindIdByOwner } from '../../../common/files'; import { DEFAULT_BASE_PATH } from '../../common/navigation'; -import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer'; +import type { CasesContextStoreAction } from './state/cases_context_reducer'; +import { casesContextReducer, getInitialCasesContextState } from './state/cases_context_reducer'; +import { CasesStateContext } from './state/cases_state_context'; import { isRegisteredOwner } from '../../files'; import { casesQueryClient } from './query_client'; @@ -152,14 +153,16 @@ export const CasesProvider: FC< return ( - - {applyFilesContext( - <> - - {children} - - )} - + + + {applyFilesContext( + <> + + {children} + + )} + + ); }; diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts b/x-pack/plugins/cases/public/components/cases_context/state/cases_context_reducer.ts similarity index 93% rename from x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts rename to x-pack/plugins/cases/public/components/cases_context/state/cases_context_reducer.ts index 1ab468e246bdd..420ab2d38eba6 100644 --- a/x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts +++ b/x-pack/plugins/cases/public/components/cases_context/state/cases_context_reducer.ts @@ -6,8 +6,8 @@ */ import { assertNever } from '@kbn/std'; -import type { AllCasesSelectorModalProps } from '../all_cases/selector_modal'; -import type { CreateCaseFlyoutProps } from '../create/flyout'; +import type { AllCasesSelectorModalProps } from '../../all_cases/selector_modal'; +import type { CreateCaseFlyoutProps } from '../../create/flyout'; export const getInitialCasesContextState = (): CasesContextState => { return { diff --git a/x-pack/plugins/cases/public/components/cases_context/state/cases_state_context.ts b/x-pack/plugins/cases/public/components/cases_context/state/cases_state_context.ts new file mode 100644 index 0000000000000..14a8c01bef902 --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/state/cases_state_context.ts @@ -0,0 +1,21 @@ +/* + * 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, { useContext } from 'react'; +import type { CasesContextState } from './cases_context_reducer'; + +export const CasesStateContext = React.createContext(undefined); + +export const useCasesStateContext = () => { + const casesStateContext = useContext(CasesStateContext); + if (!casesStateContext) { + throw new Error( + 'useCasesStateContext must be used within a CasesProvider and have a defined value. See https://github.com/elastic/kibana/blob/main/x-pack/plugins/cases/README.md#cases-ui' + ); + } + return casesStateContext; +}; diff --git a/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.test.tsx b/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.test.tsx new file mode 100644 index 0000000000000..9974d0cb530d9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal'; +import { createAppMockRenderer } from '../../../common/mock'; +import { useIsAddToCaseOpen } from './use_is_add_to_case_open'; +import { useCasesToast } from '../../../common/use_cases_toast'; +import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout'; + +jest.mock('../../../common/use_cases_toast'); +const useCasesToastMock = useCasesToast as jest.Mock; +useCasesToastMock.mockReturnValue({ + showInfoToast: jest.fn(), +}); + +const { AppWrapper } = createAppMockRenderer(); + +describe('use is add to existing case modal open hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw if called outside of a cases context', () => { + const { result } = renderHook(useIsAddToCaseOpen); + expect(result.error?.message).toContain( + 'useCasesStateContext must be used within a CasesProvider and have a defined value' + ); + }); + + it('should return false when the add to case modal and flyout are not open', async () => { + const { result } = renderHook(useIsAddToCaseOpen, { wrapper: AppWrapper }); + expect(result.current).toEqual(false); + }); + + it('should return true when the add to existing case modal opens', async () => { + const { result, rerender } = renderHook( + () => { + return { + modal: useCasesAddToExistingCaseModal(), + isOpen: useIsAddToCaseOpen(), + }; + }, + { wrapper: AppWrapper } + ); + + expect(result.current.isOpen).toEqual(false); + act(() => { + result.current.modal.open(); + }); + rerender(); + expect(result.current.isOpen).toEqual(true); + }); + + it('should return true when the add to new case flyout opens', async () => { + const { result, rerender } = renderHook( + () => { + return { + flyout: useCasesAddToNewCaseFlyout(), + isOpen: useIsAddToCaseOpen(), + }; + }, + { wrapper: AppWrapper } + ); + + expect(result.current.isOpen).toEqual(false); + act(() => { + result.current.flyout.open(); + }); + rerender(); + expect(result.current.isOpen).toEqual(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.ts b/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.ts new file mode 100644 index 0000000000000..7e385e3c6b0fe --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/state/use_is_add_to_case_open.ts @@ -0,0 +1,18 @@ +/* + * 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 { useCasesStateContext } from './cases_state_context'; + +export type UseIsAddToCaseOpen = () => boolean; + +/** + * This hook is to check if the "add to case" is open, either the modal or the flyout + */ +export const useIsAddToCaseOpen: UseIsAddToCaseOpen = () => { + const { selectCaseModal, createCaseFlyout } = useCasesStateContext(); + return selectCaseModal.isModalOpen || createCaseFlyout.isFlyoutOpen; +}; diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx index a7aa207162989..168cae0e478fc 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -10,7 +10,7 @@ import { renderHook } from '@testing-library/react-hooks'; import type { FC, PropsWithChildren } from 'react'; import React from 'react'; import { CasesContext } from '../../cases_context'; -import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { CasesContextStoreActionsList } from '../../cases_context/state/cases_context_reducer'; import { useCasesAddToNewCaseFlyout } from './use_cases_add_to_new_case_flyout'; import { allCasesPermissions } from '../../../common/mock'; import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry'; diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index ea2290bb49633..6ee5b87bde9fc 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react'; import type { CaseAttachmentsWithoutOwner } from '../../../types'; import { useCasesToast } from '../../../common/use_cases_toast'; import type { CaseUI } from '../../../containers/types'; -import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { CasesContextStoreActionsList } from '../../cases_context/state/cases_context_reducer'; import { useCasesContext } from '../../cases_context/use_cases_context'; import type { CreateCaseFlyoutProps } from './create_case_flyout'; diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index 3511beda3cccc..e267c108a9b39 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -27,6 +27,7 @@ const uiMock: jest.Mocked = { export const openAddToExistingCaseModalMock = jest.fn(); export const openAddToNewCaseFlyoutMock = jest.fn(); +export const isAddToCaseOpenMock = jest.fn(); const hooksMock: jest.Mocked = { useCasesAddToNewCaseFlyout: jest.fn().mockImplementation(() => ({ @@ -35,6 +36,7 @@ const hooksMock: jest.Mocked = { useCasesAddToExistingCaseModal: jest.fn().mockImplementation(() => ({ open: openAddToExistingCaseModalMock, })), + useIsAddToCaseOpen: isAddToCaseOpenMock, }; const helpersMock: jest.Mocked = { diff --git a/x-pack/plugins/cases/public/plugin.test.ts b/x-pack/plugins/cases/public/plugin.test.ts index bfe00078a04d6..54f9cf070df44 100644 --- a/x-pack/plugins/cases/public/plugin.test.ts +++ b/x-pack/plugins/cases/public/plugin.test.ts @@ -142,6 +142,7 @@ describe('Cases Ui Plugin', () => { hooks: { useCasesAddToExistingCaseModal: expect.any(Function), useCasesAddToNewCaseFlyout: expect.any(Function), + useIsAddToCaseOpen: expect.any(Function), }, ui: { getAllCasesSelectorModal: expect.any(Function), diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 44393473767e6..12308fcc2f8b6 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -16,6 +16,7 @@ import { APP_ID, APP_PATH } from '../common/constants'; import { APP_TITLE, APP_DESC } from './common/translations'; import { useCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal'; import { useCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; +import { useIsAddToCaseOpen } from './components/cases_context/state/use_is_add_to_case_open'; import { createClientAPI } from './client/api'; import { canUseCases } from './client/helpers/can_use_cases'; import { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event'; @@ -190,6 +191,7 @@ export class CasesUiPlugin hooks: { useCasesAddToNewCaseFlyout, useCasesAddToExistingCaseModal, + useIsAddToCaseOpen, }, helpers: { canUseCases: canUseCases(core.application.capabilities), diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index bb8790a299d12..c857446ea042c 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -32,6 +32,7 @@ import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverle import type { UseCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal'; import type { UseCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; +import type { UseIsAddToCaseOpen } from './components/cases_context/state/use_is_add_to_case_open'; import type { canUseCases } from './client/helpers/can_use_cases'; import type { getRuleIdFromEvent } from './client/helpers/get_rule_id_from_event'; import type { GetCasesContextProps } from './client/ui/get_cases_context'; @@ -154,6 +155,7 @@ export interface CasesPublicStart { hooks: { useCasesAddToNewCaseFlyout: UseCasesAddToNewCaseFlyout; useCasesAddToExistingCaseModal: UseCasesAddToExistingCaseModal; + useIsAddToCaseOpen: UseIsAddToCaseOpen; }; helpers: { /** diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx index 0dd60a0f9c2f8..a6500a2ba5d10 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx @@ -32,6 +32,7 @@ jest.mock('../../../utils/route/use_route_spy'); jest.mock('@kbn/expandable-flyout', () => { return { useExpandableFlyoutApi: () => ({ openFlyout: mockOpenFlyout }), + useExpandableFlyoutState: () => ({ left: false }), }; }); @@ -55,6 +56,7 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { ...original, }; }); +jest.mock('../../guided_onboarding_tour/tour_step'); const mockRouteSpy: RouteSpyState = { pageName: SecurityPageName.overview, diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index bc9969d2a7e42..181b707e88d2d 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -30,6 +30,8 @@ import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/sea import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline'; import { TimelineId } from '../../../../../common/types'; import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; +import { useTourContext } from '../../guided_onboarding_tour'; +import { AlertsCasesTourSteps, SecurityStepId } from '../../guided_onboarding_tour/tour_config'; type Props = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; @@ -83,6 +85,11 @@ const RowActionComponent = ({ const isExpandableFlyoutInCreateRuleEnabled = useIsExperimentalFeatureEnabled( 'expandableFlyoutInCreateRuleEnabled' ); + const { activeStep, isTourShown } = useTourContext(); + const shouldFocusOnOverviewTab = + (activeStep === AlertsCasesTourSteps.expandEvent || + activeStep === AlertsCasesTourSteps.reviewAlertDetailsFlyout) && + isTourShown(SecurityStepId.alertsCases); const columnValues = useMemo( () => @@ -123,6 +130,7 @@ const RowActionComponent = ({ openFlyout({ right: { id: DocumentDetailsRightPanelKey, + path: shouldFocusOnOverviewTab ? { tab: 'overview' } : undefined, params: { id: eventId, indexName, @@ -156,7 +164,17 @@ const RowActionComponent = ({ }) ); } - }, [dispatch, eventId, indexName, openFlyout, tabType, tableId, showExpandableFlyout, telemetry]); + }, [ + eventId, + indexName, + showExpandableFlyout, + tableId, + openFlyout, + shouldFocusOnOverviewTab, + telemetry, + dispatch, + tabType, + ]); const Action = controlColumn.rowCellRender; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index f03e204301d87..d7e4a92fe5a2a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -57,6 +57,12 @@ jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallbac }; }); +jest.mock('../guided_onboarding_tour/tour_step', () => ({ + GuidedOnboardingTourStep: jest.fn(({ children }) => ( +
{children}
+ )), +})); + jest.mock('../link_to'); describe('EventDetails', () => { const defaultProps = { @@ -168,6 +174,10 @@ describe('EventDetails', () => { EVENT_DETAILS_CONTEXT_ID ); }); + + test('renders GuidedOnboardingTourStep', () => { + expect(alertsWrapper.find('[data-test-subj="guided-onboarding"]').exists()).toEqual(true); + }); }); describe('threat intel tab', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 6c1dd9bce910a..70d0c29eeda4b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -231,104 +231,111 @@ const EventDetailsComponent: React.FC = ({ name: i18n.OVERVIEW, 'data-test-subj': 'overviewTab', content: ( - <> - - - - {threatDetails && threatDetails[0] && ( - - <> - -
{threatDetails[0].title}
-
- - {threatDetails[0].description} - - -
- )} - - {renderer != null && detailsEcsData != null && ( -
- -
{i18n.ALERT_REASON}
-
- - - {renderer.renderRow({ - contextId: EVENT_DETAILS_CONTEXT_ID, - data: detailsEcsData, - isDraggable: isDraggable ?? false, - scopeId, - })} - -
- )} - - - - - - {showThreatSummary && ( - + <> + + + + {threatDetails && threatDetails[0] && ( + + <> + +
{threatDetails[0].title}
+
+ + {threatDetails[0].description} + + +
+ )} + + {renderer != null && detailsEcsData != null && ( +
+ +
{i18n.ALERT_REASON}
+
+ + + {renderer.renderRow({ + contextId: EVENT_DETAILS_CONTEXT_ID, + data: detailsEcsData, + isDraggable: isDraggable ?? false, + scopeId, + })} + +
+ )} + + + + - )} - {isEnrichmentsLoading && ( - <> - - - )} + {showThreatSummary && ( + + )} + + {isEnrichmentsLoading && ( + <> + + + )} - {basicAlertData.ruleId && maybeRule?.note && ( - - )} - + {basicAlertData.ruleId && maybeRule?.note && ( + + )} + + ), } : undefined, [ isAlert, + isTourAnchor, browserFields, scopeId, data, @@ -340,7 +347,7 @@ const EventDetailsComponent: React.FC = ({ detailsEcsData, isDraggable, goToTableTab, - maybeRule?.investigation_fields, + maybeRule?.investigation_fields?.field_names, maybeRule?.note, showThreatSummary, hostRisk, @@ -469,23 +476,17 @@ const EventDetailsComponent: React.FC = ({ ); return ( - - <> - - - - + <> + + + ); }; EventDetailsComponent.displayName = 'EventDetailsComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx index 52a6d5eb1eb42..54c30fa38a588 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx @@ -7,7 +7,7 @@ import { act, render, screen } from '@testing-library/react'; import React from 'react'; - +import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; import { TestProviders } from '../../../mock'; import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__'; import { RelatedCases } from './related_cases'; @@ -17,13 +17,17 @@ import { useTourContext } from '../../guided_onboarding_tour'; import { AlertsCasesTourSteps } from '../../guided_onboarding_tour/tour_config'; const mockedUseKibana = mockUseKibana(); -const mockGetRelatedCases = jest.fn(); -const mockCanUseCases = jest.fn(); -jest.mock('../../guided_onboarding_tour'); +const mockCasesContract = casesPluginMock.createStartContract(); +const mockGetRelatedCases = mockCasesContract.api.getRelatedCases as jest.Mock; +mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); +const mockCanUseCases = mockCasesContract.helpers.canUseCases as jest.Mock; +mockCanUseCases.mockReturnValue(readCasesPermissions()); + +const mockUseTourContext = useTourContext as jest.Mock; + jest.mock('../../../lib/kibana', () => { const original = jest.requireActual('../../../lib/kibana'); - return { ...original, useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }), @@ -31,68 +35,63 @@ jest.mock('../../../lib/kibana', () => { ...mockedUseKibana, services: { ...mockedUseKibana.services, - cases: { - api: { - getRelatedCases: mockGetRelatedCases, - }, - helpers: { canUseCases: mockCanUseCases }, - }, + cases: mockCasesContract, }, }), }; }); +jest.mock('../../guided_onboarding_tour'); +const defaultUseTourContextValue = { + activeStep: AlertsCasesTourSteps.viewCase, + incrementStep: () => null, + endTourStep: () => null, + isTourShown: () => false, +}; + +jest.mock('../../guided_onboarding_tour/tour_step'); + const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a'; const scrollToMock = jest.fn(); window.HTMLElement.prototype.scrollIntoView = scrollToMock; describe('Related Cases', () => { beforeEach(() => { - mockCanUseCases.mockReturnValue(readCasesPermissions()); - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: AlertsCasesTourSteps.viewCase, - incrementStep: () => null, - endTourStep: () => null, - isTourShown: () => false, - }); + mockUseTourContext.mockReturnValue(defaultUseTourContextValue); jest.clearAllMocks(); }); + describe('When user does not have cases read permissions', () => { beforeEach(() => { mockCanUseCases.mockReturnValue(noCasesPermissions()); }); + test('should not show related cases when user does not have permissions', async () => { await act(async () => { - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(screen.queryByText('cases')).toBeNull(); }); }); + describe('When user does have case read permissions', () => { + beforeEach(() => { + mockCanUseCases.mockReturnValue(readCasesPermissions()); + }); + test('Should show the loading message', async () => { + mockGetRelatedCases.mockReturnValueOnce([]); await act(async () => { - mockGetRelatedCases.mockReturnValue([]); - render( - - - - ); - expect(screen.getByText(CASES_LOADING)).toBeInTheDocument(); + render(, { wrapper: TestProviders }); + expect(screen.queryByText(CASES_LOADING)).toBeInTheDocument(); }); + expect(screen.queryByText(CASES_LOADING)).not.toBeInTheDocument(); }); test('Should show 0 related cases when there are none', async () => { + mockGetRelatedCases.mockReturnValueOnce([]); await act(async () => { - mockGetRelatedCases.mockReturnValue([]); - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument(); @@ -100,28 +99,19 @@ describe('Related Cases', () => { test('Should show 1 related case', async () => { await act(async () => { - mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument(); expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case'); }); test('Should show 2 related cases', async () => { + mockGetRelatedCases.mockReturnValueOnce([ + { id: '789', title: 'Test Case 1' }, + { id: '456', title: 'Test Case 2' }, + ]); await act(async () => { - mockGetRelatedCases.mockReturnValue([ - { id: '789', title: 'Test Case 1' }, - { id: '456', title: 'Test Case 2' }, - ]); - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument(); const cases = screen.getAllByTestId('case-details-link'); @@ -131,13 +121,8 @@ describe('Related Cases', () => { }); test('Should not open the related cases accordion when isTourActive=false', async () => { - mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); await act(async () => { - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(scrollToMock).not.toHaveBeenCalled(); expect( @@ -146,19 +131,13 @@ describe('Related Cases', () => { }); test('Should automatically open the related cases accordion when isTourActive=true', async () => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: AlertsCasesTourSteps.viewCase, - incrementStep: () => null, - endTourStep: () => null, + // this hook is called twice, so we can not use mockReturnValueOnce + mockUseTourContext.mockReturnValue({ + ...defaultUseTourContextValue, isTourShown: () => true, }); - mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); await act(async () => { - render( - - - - ); + render(, { wrapper: TestProviders }); }); expect(scrollToMock).toHaveBeenCalled(); expect( diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.tsx new file mode 100644 index 0000000000000..fe1ce6c6b1c1d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.tsx @@ -0,0 +1,12 @@ +/* + * 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'; + +export const GuidedOnboardingTourStep = jest.fn(({ children }) => ( +
{children}
+)); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx index 2cf0153a1a3fe..a4fca35acf56b 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx @@ -23,6 +23,8 @@ export interface TourContextValue { incrementStep: (tourId: SecurityStepId) => void; isTourShown: (tourId: SecurityStepId) => boolean; setStep: (tourId: SecurityStepId, step: AlertsCasesTourSteps) => void; + hidden: boolean; + setAllTourStepsHidden: (h: boolean) => void; } const initialState: TourContextValue = { @@ -31,12 +33,19 @@ const initialState: TourContextValue = { incrementStep: () => {}, isTourShown: () => false, setStep: () => {}, + hidden: false, + setAllTourStepsHidden: () => {}, }; const TourContext = createContext(initialState); export const RealTourContextProvider = ({ children }: { children: ReactChild }) => { const { guidedOnboarding } = useKibana().services; + const [hidden, setHidden] = useState(false); + + const setAllTourStepsHidden = useCallback((h: boolean) => { + setHidden(h); + }, []); const isRulesTourActive = useObservable( guidedOnboarding?.guidedOnboardingApi @@ -61,13 +70,16 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) const tourStatus = useMemo( () => ({ - [SecurityStepId.rules]: isRulesTourActive, - [SecurityStepId.alertsCases]: isAlertsCasesTourActive, + [SecurityStepId.rules]: { active: isRulesTourActive, hidden: false }, + [SecurityStepId.alertsCases]: { active: isAlertsCasesTourActive, hidden: false }, }), [isRulesTourActive, isAlertsCasesTourActive] ); - const isTourShown = useCallback((tourId: SecurityStepId) => tourStatus[tourId], [tourStatus]); + const isTourShown = useCallback( + (tourId: SecurityStepId) => tourStatus[tourId].active, + [tourStatus] + ); const [activeStep, _setActiveStep] = useState(1); const incrementStep = useCallback((tourId: SecurityStepId) => { @@ -106,13 +118,15 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) const context = useMemo(() => { return { + hidden, + setAllTourStepsHidden, activeStep, endTourStep, incrementStep, isTourShown, setStep, }; - }, [activeStep, endTourStep, incrementStep, isTourShown, setStep]); + }, [activeStep, endTourStep, hidden, incrementStep, isTourShown, setAllTourStepsHidden, setStep]); return {children}; }; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts index ca52149bfc329..dd04b76d061a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -59,6 +59,23 @@ const defaultConfig = { export const getTourAnchor = (step: number, tourId: SecurityStepId) => `tourStepAnchor-${tourId}-${step}`; +export const hiddenWhenLeftExpandableFlyoutExpanded: Record = { + [SecurityStepId.alertsCases]: [ + AlertsCasesTourSteps.pointToAlertName, + AlertsCasesTourSteps.expandEvent, + ], +}; + +export const hiddenWhenCaseFlyoutExpanded: Record = { + [SecurityStepId.alertsCases]: [ + AlertsCasesTourSteps.pointToAlertName, + AlertsCasesTourSteps.expandEvent, + AlertsCasesTourSteps.reviewAlertDetailsFlyout, + AlertsCasesTourSteps.addAlertToCase, + AlertsCasesTourSteps.viewCase, + ], +}; + const alertsCasesConfig: StepConfig[] = [ { ...defaultConfig, diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx index cf693392f83c4..0490bf882edd0 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx @@ -12,15 +12,38 @@ import { AlertsCasesTourSteps, SecurityStepId } from './tour_config'; import { useTourContext } from './tour'; import { mockGlobalState, TestProviders, createMockStore } from '../../mock'; import { TimelineId } from '../../../../common/types'; +import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; +import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__'; +import { useHiddenByFlyout } from './use_hidden_by_flyout'; + +const mockedUseKibana = mockUseKibana(); +const mockCasesContract = casesPluginMock.createStartContract(); +const mockUseIsAddToCaseOpen = mockCasesContract.hooks.useIsAddToCaseOpen as jest.Mock; +mockUseIsAddToCaseOpen.mockReturnValue(false); +const mockUseTourContext = useTourContext as jest.Mock; + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + return { + ...original, + useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: mockCasesContract, + }, + }), + }; +}); jest.mock('./tour'); -const mockTourStep = jest - .fn() - .mockImplementation(({ children, footerAction }: EuiTourStepProps) => ( - - {children} {footerAction} - - )); + +const useHiddenByFlyoutMock = useHiddenByFlyout as jest.Mock; +jest.mock('./use_hidden_by_flyout', () => ({ + useHiddenByFlyout: jest.fn(), +})); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { @@ -29,23 +52,37 @@ jest.mock('@elastic/eui', () => { EuiTourStep: (props: any) => mockTourStep(props), }; }); + +const mockTourStep = jest + .fn() + .mockImplementation(({ children, footerAction }: EuiTourStepProps) => ( + + {children} {footerAction} + + )); + const defaultProps = { isTourAnchor: true, - step: 1, + step: AlertsCasesTourSteps.pointToAlertName, tourId: SecurityStepId.alertsCases, }; const mockChildren =

{'random child element'}

; +const incrementStep = jest.fn(); + +const defaultUseTourContextValue = { + activeStep: AlertsCasesTourSteps.pointToAlertName, + incrementStep, + endTourStep: jest.fn(), + isTourShown: jest.fn(() => true), + hidden: false, +}; describe('GuidedOnboardingTourStep', () => { - const incrementStep = jest.fn(); beforeEach(() => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: 1, - incrementStep, - isTourShown: () => true, - }); jest.clearAllMocks(); + mockUseTourContext.mockReturnValue(defaultUseTourContextValue); + useHiddenByFlyoutMock.mockReturnValue(false); }); it('renders as a tour step', () => { const { getByTestId } = render( @@ -57,6 +94,17 @@ describe('GuidedOnboardingTourStep', () => { expect(tourStep).toBeInTheDocument(); expect(header).toBeInTheDocument(); }); + it('useHiddenByFlyout equals to true, just render children', () => { + useHiddenByFlyoutMock.mockReturnValue(true); + const { getByTestId, queryByTestId } = render( + {mockChildren}, + { wrapper: TestProviders } + ); + const tourStep = queryByTestId('tourStepMock'); + const header = getByTestId('h1'); + expect(tourStep).not.toBeInTheDocument(); + expect(header).toBeInTheDocument(); + }); it('isTourAnchor={false}, just render children', () => { const { getByTestId, queryByTestId } = render( @@ -100,19 +148,14 @@ describe('GuidedOnboardingTourStep', () => { describe('SecurityTourStep', () => { const { isTourAnchor: _, ...stepDefaultProps } = defaultProps; beforeEach(() => { - (useTourContext as jest.Mock).mockReturnValue({ - activeStep: 1, - incrementStep: jest.fn(), - isTourShown: () => true, - }); + (useTourContext as jest.Mock).mockReturnValue(defaultUseTourContextValue); jest.clearAllMocks(); }); it('does not render if tour step does not exist', () => { - (useTourContext as jest.Mock).mockReturnValue({ + mockUseTourContext.mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 99, - incrementStep: jest.fn(), - isTourShown: () => true, }); render( @@ -134,10 +177,10 @@ describe('SecurityTourStep', () => { }); it('does not render if security tour step is not shown', () => { - (useTourContext as jest.Mock).mockReturnValue({ + mockUseTourContext.mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 1, - incrementStep: jest.fn(), - isTourShown: () => false, + isTourShown: jest.fn(() => false), }); render({mockChildren}, { wrapper: TestProviders, @@ -166,10 +209,9 @@ describe('SecurityTourStep', () => { }); it('renders next button', () => { - (useTourContext as jest.Mock).mockReturnValue({ + mockUseTourContext.mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 3, - incrementStep: jest.fn(), - isTourShown: () => true, }); const { getByTestId } = render( @@ -182,9 +224,8 @@ describe('SecurityTourStep', () => { it('if a step has an anchor declared, the tour step should be a sibling of the mockChildren', () => { (useTourContext as jest.Mock).mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 3, - incrementStep: jest.fn(), - isTourShown: () => true, }); const { container } = render( @@ -203,10 +244,9 @@ describe('SecurityTourStep', () => { }); it('if a step does not an anchor declared, the tour step should be the parent of the mockChildren', () => { - (useTourContext as jest.Mock).mockReturnValue({ + mockUseTourContext.mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 2, - incrementStep: jest.fn(), - isTourShown: () => true, }); const { container } = render( @@ -265,9 +305,8 @@ describe('SecurityTourStep', () => { it('does not render next button if step hideNextButton=true ', () => { (useTourContext as jest.Mock).mockReturnValue({ + ...defaultUseTourContextValue, activeStep: 6, - incrementStep: jest.fn(), - isTourShown: () => true, }); const { queryByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx index 156604160be74..4f3d06eac53d5 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx @@ -18,6 +18,7 @@ import { timelineDefaults } from '../../../timelines/store/defaults'; import { timelineSelectors } from '../../../timelines/store'; import { useTourContext } from './tour'; import { AlertsCasesTourSteps, SecurityStepId, securityTourConfig } from './tour_config'; +import { useHiddenByFlyout } from './use_hidden_by_flyout'; interface SecurityTourStep { children?: React.ReactElement; @@ -37,104 +38,113 @@ const StyledTourStep = styled(EuiTourStep) { - const { activeStep, incrementStep, isTourShown } = useTourContext(); - const tourStep = useMemo( - () => securityTourConfig[tourId].find((config) => config.step === step), - [step, tourId] - ); - - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const showTimeline = useShallowEqualSelector( - (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show - ); - - const onClickNext = useCallback( - // onClick should call incrementStep itself - () => (onClick ? onClick() : incrementStep(tourId)), - [incrementStep, onClick, tourId] - ); - - // EUI bug, will remove once bug resolve. will link issue here as soon as i have it - const onKeyDown = useCallback((e) => { - if (e.key === 'Enter') { - e.stopPropagation(); +export const SecurityTourStep = React.memo( + ({ children, onClick, step, tourId }: SecurityTourStep) => { + const { activeStep, incrementStep, isTourShown } = useTourContext(); + const tourStep = useMemo( + () => securityTourConfig[tourId].find((config) => config.step === step), + [step, tourId] + ); + + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const showTimeline = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show + ); + const onClickNext = useCallback( + // onClick should call incrementStep itself + () => (onClick ? onClick() : incrementStep(tourId)), + [incrementStep, onClick, tourId] + ); + + // EUI bug, will remove once bug resolve. will link issue here as soon as i have it + const onKeyDown = useCallback((e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + } + }, []); + + // steps in Cases app are out of context. + // If we mount this step, we know we need to render it + // we are also managing the context on the siem end in the background + const overrideContext = isStepExternallyMounted(tourId, step); + + if ( + tourStep == null || + ((step !== activeStep || !isTourShown(tourId)) && !overrideContext) || + showTimeline + ) { + return children ? children : null; } - }, []); - - // steps in Cases app are out of context. - // If we mount this step, we know we need to render it - // we are also managing the context on the siem end in the background - const overrideContext = isStepExternallyMounted(tourId, step); - - if ( - tourStep == null || - ((step !== activeStep || !isTourShown(tourId)) && !overrideContext) || - showTimeline - ) { - return children ? children : null; - } - const { anchor, content, imageConfig, dataTestSubj, hideNextButton = false, ...rest } = tourStep; - const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? ( - - - - ) : ( - <> - {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/} - - ); - - const commonProps = { - ...rest, - content: ( + const { + anchor, + content, + imageConfig, + dataTestSubj, + hideNextButton = false, + ...rest + } = tourStep; + const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? ( + + + + ) : ( <> - -

{content}

-
- {imageConfig && ( - <> - - - - )} + {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/} - ), - footerAction, - // we would not have mounted this component if it was not open - isStepOpen: true, - // guided onboarding does not allow skipping tour through the steps - onFinish: () => null, - stepsTotal: securityTourConfig[tourId].length, - panelProps: { - 'data-test-subj': dataTestSubj, - }, - }; - - // tour step either needs children or an anchor element - // see type EuiTourStepAnchorProps - return anchor != null ? ( - <> - - <>{children} - - ) : children != null ? ( - - {children} - - ) : null; -}; + ); + + const commonProps = { + ...rest, + content: ( + <> + +

{content}

+
+ {imageConfig && ( + <> + + + + )} + + ), + footerAction, + // we would not have mounted this component if it was not open + isStepOpen: true, + // guided onboarding does not allow skipping tour through the steps + onFinish: () => null, + stepsTotal: securityTourConfig[tourId].length, + panelProps: { + 'data-test-subj': dataTestSubj, + }, + }; + + // tour step either needs children or an anchor element + // see type EuiTourStepAnchorProps + return anchor != null ? ( + <> + + <>{children} + + ) : children != null ? ( + + {children} + + ) : null; + } +); +SecurityTourStep.displayName = 'SecurityTourStep'; interface GuidedOnboardingTourStep extends SecurityTourStep { // can be false if the anchor is an iterative element @@ -145,11 +155,30 @@ interface GuidedOnboardingTourStep extends SecurityTourStep { // wraps tour anchor component // and gives the tour step itself a place to mount once it is active // mounts the tour step with a delay to ensure the anchor will render first -export const GuidedOnboardingTourStep = ({ - children, - // can be false if the anchor is an iterative element - // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway - isTourAnchor = true, - ...props -}: GuidedOnboardingTourStep) => - isTourAnchor ? {children} : <>{children}; +export const GuidedOnboardingTourStep = React.memo( + ({ + children, + // can be false if the anchor is an iterative element + // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway + isTourAnchor = true, + ...props + }: GuidedOnboardingTourStep) => { + return isTourAnchor ? ( + {children} + ) : ( + <>{children} + ); + } +); +GuidedOnboardingTourStep.displayName = 'GuidedOnboardingTourStep'; + +const SecurityTourStepAnchor = React.memo(({ children, ...props }: SecurityTourStep) => { + const { hidden: allStepsHidden } = useTourContext(); + const hiddenByFlyout = useHiddenByFlyout({ tourId: props.tourId, step: props.step }); + return !allStepsHidden && !hiddenByFlyout ? ( + {children} + ) : ( + <>{children} + ); +}); +SecurityTourStepAnchor.displayName = 'SecurityTourStepAnchor'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts new file mode 100644 index 0000000000000..c0c96be21610f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts @@ -0,0 +1,60 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import type { RisonValue } from '@kbn/rison'; +import type { AlertsCasesTourSteps } from './tour_config'; +import { + hiddenWhenCaseFlyoutExpanded, + hiddenWhenLeftExpandableFlyoutExpanded, + SecurityStepId, +} from './tour_config'; +import { useKibana } from '../../lib/kibana'; +import { URL_PARAM_KEY } from '../../hooks/use_url_state'; +import { getObjectFromQueryString } from '../../utils/global_query_string/helpers'; + +interface UseHiddenByFlyoutProps { + tourId: SecurityStepId; + step: AlertsCasesTourSteps; +} + +/* + ** To check if given Guided tour step should be hidden when the LEFT expandable flyout + ** or any case modal is opened + */ +export const useHiddenByFlyout = ({ tourId, step }: UseHiddenByFlyoutProps) => { + const { useIsAddToCaseOpen } = useKibana().services.cases.hooks; + const isAddToCaseOpen = useIsAddToCaseOpen(); + + const { search } = useLocation(); + + const expandableFlyoutKey = useMemo( + () => + getObjectFromQueryString<{ left: RisonValue; right: RisonValue }>( + URL_PARAM_KEY.flyout, + search + ), + [search] + ); + + const isExpandableFlyoutExpanded = expandableFlyoutKey?.left; + + const hiddenWhenExpandableFlyoutOpened = useMemo( + () => + isExpandableFlyoutExpanded && + hiddenWhenLeftExpandableFlyoutExpanded[SecurityStepId.alertsCases]?.includes(step), + [isExpandableFlyoutExpanded, step] + ); + + const hiddenWhenCasesModalFlyoutExpanded = useMemo( + () => isAddToCaseOpen && hiddenWhenCaseFlyoutExpanded[tourId]?.includes(step), + [isAddToCaseOpen, tourId, step] + ); + + return hiddenWhenExpandableFlyoutOpened || hiddenWhenCasesModalFlyoutExpanded; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx index c2f6f8af965f8..9c79d28ce2069 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx @@ -19,7 +19,12 @@ import { SecurityStepId } from '../guided_onboarding_tour/tour_config'; import { Actions } from './actions'; import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../user_privileges/user_privileges_context'; import { useUserPrivileges } from '../user_privileges'; +import { useHiddenByFlyout } from '../guided_onboarding_tour/use_hidden_by_flyout'; +const useHiddenByFlyoutMock = useHiddenByFlyout as jest.Mock; +jest.mock('../guided_onboarding_tour/use_hidden_by_flyout', () => ({ + useHiddenByFlyout: jest.fn(), +})); jest.mock('../guided_onboarding_tour'); jest.mock('../user_privileges'); jest.mock('../../../detections/components/user_info', () => ({ @@ -203,6 +208,19 @@ describe('Actions', () => { expect(wrapper.find(SecurityTourStep).exists()).toEqual(true); }); + test('if left expandable flyout is expanded, SecurityTourStep not active', () => { + useHiddenByFlyoutMock.mockReturnValue(true); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(GuidedOnboardingTourStep).exists()).toEqual(true); + expect(wrapper.find(SecurityTourStep).exists()).toEqual(false); + }); + test('on expand event click and SecurityTourStep is active, incrementStep', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx new file mode 100644 index 0000000000000..69f1b5fcbf4e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 { + isDetectionsPages, + getQueryStringFromLocation, + getParamFromQueryString, + getObjectFromQueryString, + useGetInitialUrlParamValue, + encodeQueryString, + useReplaceUrlParams, + createHistoryEntry, +} from './helpers'; +import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import React from 'react'; + +const flyoutString = + "(left:(id:document-details-left,params:(id:'04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',indexName:.internal.alerts-security.alerts-default-000001,scopeId:alerts-page)),right:(id:document-details-right,params:(id:'04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',indexName:.internal.alerts-security.alerts-default-000001,scopeId:alerts-page)))"; +const testString = `sourcerer=(default:(id:security-solution-default,selectedPatterns:!(.alerts-security.alerts-default)))&timerange=(global:(linkTo:!(),timerange:(from:%272024-05-14T23:00:00.000Z%27,fromStr:now%2Fd,kind:relative,to:%272024-05-15T22:59:59.999Z%27,toStr:now%2Fd)),timeline:(linkTo:!(),timerange:(from:%272024-05-14T09:32:36.347Z%27,kind:absolute,to:%272024-05-15T09:32:36.347Z%27)))&timeline=(activeTab:query,graphEventId:%27%27,isOpen:!f)&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,hideActionBar:!t,selectedOptions:!(open),title:Status),(exclude:!f,existsSelected:!f,fieldName:kibana.alert.severity,hideActionBar:!t,selectedOptions:!(),title:Severity),(exclude:!f,existsSelected:!f,fieldName:user.name,hideActionBar:!f,selectedOptions:!(),title:User),(exclude:!f,existsSelected:!f,fieldName:host.name,hideActionBar:!f,selectedOptions:!(),title:Host))&flyout=${flyoutString}&timelineFlyout=()`; + +const flyoutObject = { + left: { + id: 'document-details-left', + params: { + id: '04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c', + indexName: '.internal.alerts-security.alerts-default-000001', + scopeId: 'alerts-page', + }, + }, + right: { + id: 'document-details-right', + params: { + id: '04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c', + indexName: '.internal.alerts-security.alerts-default-000001', + scopeId: 'alerts-page', + }, + }, +}; + +describe('helpers', () => { + describe('isDetectionsPages', () => { + it('returns true for detections pages', () => { + expect(isDetectionsPages('alerts')).toBe(true); + expect(isDetectionsPages('rules')).toBe(true); + expect(isDetectionsPages('rules-add')).toBe(true); + expect(isDetectionsPages('rules-create')).toBe(true); + expect(isDetectionsPages('exceptions')).toBe(true); + }); + + it('returns false for non-detections pages', () => { + expect(isDetectionsPages('otherPage')).toBe(false); + }); + }); + + describe('getQueryStringFromLocation', () => { + it('returns the query string without the leading "?"', () => { + expect(getQueryStringFromLocation('?param=value')).toBe('param=value'); + }); + }); + + describe('getParamFromQueryString', () => { + it('returns the value of the specified query parameter', () => { + expect(getParamFromQueryString(testString, 'flyout')).toBe(flyoutString); + }); + + it('returns undefined if the query parameter is not found', () => { + expect(getParamFromQueryString(testString, 'param')).toBeUndefined(); + }); + + it('returns the first value if the query parameter is an array', () => { + const queryString = 'param1=value1¶m1=value2'; + expect(getParamFromQueryString(queryString, 'param1')).toBe('value1'); + }); + }); + + describe('getObjectFromQueryString', () => { + it('returns the decoded value of the specified query parameter', () => { + expect(getObjectFromQueryString('flyout', testString)).toEqual(flyoutObject); + }); + + it('returns null if the query parameter is not found', () => { + expect(getObjectFromQueryString('param', testString)).toBeNull(); + }); + }); + + describe('useGetInitialUrlParamValue', () => { + it('returns a function that gets the initial URL parameter value', () => { + window.history.pushState({}, '', `?${testString}`); + const { result } = renderHook(() => useGetInitialUrlParamValue('flyout')); + expect(result.current()).toEqual(flyoutObject); + }); + }); + + describe('encodeQueryString', () => { + it('returns an encoded query string from the given parameters', () => { + const params = { param1: 'value1', param2: 'value2' }; + expect(encodeQueryString(params)).toBe('param1=value1¶m2=value2'); + }); + }); + + describe('useReplaceUrlParams', () => { + it('replaces URL parameters correctly', () => { + const history = createMemoryHistory(); + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useReplaceUrlParams(), { wrapper: Wrapper }); + + window.history.pushState({}, '', '?param1=value1'); + result.current({ param1: 'value2' }); + expect(history.location.search).toBe('?param1=value2'); + }); + }); + + describe('createHistoryEntry', () => { + it('creates a new history entry', () => { + const initialHistoryLength = window.history.length; + createHistoryEntry(); + expect(window.history.length).toBe(initialHistoryLength + 1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts index 1be01959e2d0a..af3cd8161a778 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts @@ -33,6 +33,17 @@ export const getParamFromQueryString = ( return Array.isArray(queryParam) ? queryParam[0] : queryParam; }; +export const getObjectFromQueryString = ( + urlParamKey: string, + search?: string +) => { + const rawParamValue = getParamFromQueryString( + getQueryStringFromLocation(search ?? window.location.search), + urlParamKey + ); + return safeDecode(rawParamValue ?? '') as State | null; +}; + /** * * Gets the value of the URL param from the query string. @@ -44,15 +55,10 @@ export const useGetInitialUrlParamValue = ( ): (() => State | null) => { // window.location.search provides the most updated representation of the url search. // It also guarantees that we don't overwrite URL param managed outside react-router. - const getInitialUrlParamValue = useCallback((): State | null => { - const rawParamValue = getParamFromQueryString( - getQueryStringFromLocation(window.location.search), - urlParamKey - ); - const paramValue = safeDecode(rawParamValue ?? '') as State | null; - - return paramValue; - }, [urlParamKey]); + const getInitialUrlParamValue = useCallback( + (): State | null => getObjectFromQueryString(urlParamKey), + [urlParamKey] + ); return getInitialUrlParamValue; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx index 85892f4ba5b53..40c855341a1e9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx @@ -9,6 +9,7 @@ import { useCallback, useState } from 'react'; import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { inputsModel } from '../../../../common/store'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; interface UseExceptionFlyoutProps { refetch?: inputsModel.Refetch; @@ -32,20 +33,26 @@ export const useExceptionFlyout = ({ onRuleChange, isActiveTimelines, }: UseExceptionFlyoutProps): UseExceptionFlyout => { + const { setAllTourStepsHidden } = useTourContext(); const [openAddExceptionFlyout, setOpenAddExceptionFlyout] = useState(false); const [exceptionFlyoutType, setExceptionFlyoutType] = useState( null ); - const onAddExceptionTypeClick = useCallback((exceptionListType?: ExceptionListTypeEnum): void => { - setExceptionFlyoutType(exceptionListType ?? null); - setOpenAddExceptionFlyout(true); - }, []); + const onAddExceptionTypeClick = useCallback( + (exceptionListType?: ExceptionListTypeEnum): void => { + setExceptionFlyoutType(exceptionListType ?? null); + setAllTourStepsHidden(true); + setOpenAddExceptionFlyout(true); + }, + [setAllTourStepsHidden] + ); const onAddExceptionCancel = useCallback(() => { setExceptionFlyoutType(null); + setAllTourStepsHidden(false); setOpenAddExceptionFlyout(false); - }, []); + }, [setAllTourStepsHidden]); const onAddExceptionConfirm = useCallback( (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert) => { @@ -55,9 +62,10 @@ export const useExceptionFlyout = ({ if (onRuleChange != null && didRuleChange) { onRuleChange(); } + setAllTourStepsHidden(false); setOpenAddExceptionFlyout(false); }, - [onRuleChange, refetch, isActiveTimelines] + [refetch, isActiveTimelines, onRuleChange, setAllTourStepsHidden] ); return { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index ff1ef27195e69..ce5126664fb14 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -49,6 +49,7 @@ jest.mock('../user_info', () => ({ })); jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/components/guided_onboarding_tour/tour_step'); jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({ useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx index 20afb552e045a..5abe8a3dfbd93 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx @@ -29,6 +29,7 @@ jest.mock('../../../common/containers/sourcerer', () => ({ indicesExist: true, }), })); +jest.mock('../../../common/components/guided_onboarding_tour/tour_step'); describe('RenderCellValue', () => { const columnId = '@timestamp'; @@ -82,4 +83,21 @@ describe('RenderCellValue', () => { expect(wrapper.find(DefaultCellRenderer).props()).toEqual(props); }); + + test('it renders a GuidedOnboardingTourStep', () => { + const RenderCellValue = getRenderCellValueHook({ + scopeId: SourcererScopeName.default, + tableId: TableId.test, + }); + + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="GuidedOnboardingTourStep"]').exists()).toEqual(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 413e9beba6015..3262e0bee184e 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -151,27 +151,27 @@ export const RenderCellValue: React.FC ); }, [ - isTourAnchor, - finalData, - browserFieldsByName, header, columnId, + browserFieldsByName, + columnHeaders, ecsData, - linkValues, - rowRenderers, + isTourAnchor, + browserFields, + finalData, + eventId, isDetails, - isExpandable, isDraggable, + isExpandable, isExpanded, + linkValues, + rowIndex, colIndex, - eventId, + rowRenderers, setCellProps, + tableId, truncate, context, - tableId, - browserFields, - rowIndex, - columnHeaders, ]); return columnId === SIGNAL_RULE_NAME_FIELD_NAME && actualSuppressionCount ? ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx index bef7b15a7a971..13df33a2deb1b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; import type { RelatedCase } from '@kbn/cases-plugin/common'; @@ -21,7 +21,7 @@ import { ExpandablePanel } from '../../../shared/components/expandable_panel'; const ICON = 'warning'; -const columns: Array> = [ +const getColumns: (data: RelatedCase[]) => Array> = (data) => [ { field: 'title', name: ( @@ -30,13 +30,16 @@ const columns: Array> = [ defaultMessage="Name" /> ), - render: (value: string, caseData: RelatedCase) => ( - - - {caseData.title} - - - ), + render: (value: string, caseData: RelatedCase) => { + const index = data.findIndex((d) => d.id === caseData.id); + return ( + + + {caseData.title} + + + ); + }, }, { field: 'status', @@ -63,6 +66,7 @@ export interface RelatedCasesProps { */ export const RelatedCases: React.FC = ({ eventId }) => { const { loading, error, data, dataCount } = useFetchRelatedCases({ eventId }); + const columns = useMemo(() => getColumns(data), [data]); if (error) { return null; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx index bb4fcdc79bb83..2ae680fc54ba9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx @@ -40,6 +40,8 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '../../../shared/components/test_ids'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; +import { AlertsCasesTourSteps } from '../../../../common/components/guided_onboarding_tour/tour_config'; jest.mock('../../shared/hooks/use_show_related_alerts_by_ancestry'); jest.mock('../../shared/hooks/use_show_related_alerts_by_same_source_event'); @@ -100,11 +102,24 @@ jest.mock('../../../../timelines/containers/use_timeline_data_filters', () => ({ })); const mockUseTimelineDataFilters = useTimelineDataFilters as jest.Mock; +jest.mock('../../../../common/components/guided_onboarding_tour', () => ({ + useTourContext: jest.fn(), +})); + const originalEventId = 'originalEventId'; describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue); + jest.mocked(useTourContext).mockReturnValue({ + hidden: false, + setAllTourStepsHidden: jest.fn(), + activeStep: AlertsCasesTourSteps.viewCase, + endTourStep: jest.fn(), + incrementStep: jest.fn(), + isTourShown: jest.fn(), + setStep: jest.fn(), + }); mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] }); }); @@ -211,4 +226,24 @@ describe('', () => { }, }); }); + + it('should navigate to the left section Insights tab automatically when active step is "view case"', () => { + render( + + + + + + ); + + expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { tab: LeftPanelInsightsTab, subTab: CORRELATIONS_TAB_ID }, + params: { + id: panelContextValue.eventId, + indexName: panelContextValue.indexName, + scopeId: panelContextValue.scopeId, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx index e6bf84f039c52..2125eb47316ae 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -30,6 +30,11 @@ import { LeftPanelInsightsTab } from '../../left'; import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; import { isActiveTimeline } from '../../../../helpers'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; +import { + AlertsCasesTourSteps, + SecurityStepId, +} from '../../../../common/components/guided_onboarding_tour/tour_config'; /** * Correlations section under Insights section, overview tab. @@ -40,6 +45,7 @@ export const CorrelationsOverview: React.FC = () => { const { dataAsNestedObject, eventId, indexName, getFieldsData, scopeId, isPreview } = useRightPanelContext(); const { openLeftPanel } = useExpandableFlyoutApi(); + const { isTourShown, activeStep } = useTourContext(); const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline(scopeId)); @@ -58,6 +64,12 @@ export const CorrelationsOverview: React.FC = () => { }); }, [eventId, openLeftPanel, indexName, scopeId]); + useEffect(() => { + if (isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase) { + goToCorrelationsTab(); + } + }, [activeStep, goToCorrelationsTab, isTourShown]); + const { show: showAlertsByAncestry, documentId } = useShowRelatedAlertsByAncestry({ getFieldsData, dataAsNestedObject, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx index 81f9c6d54457e..ebba19bd2d648 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx @@ -29,6 +29,7 @@ import { useAlertPrevalence } from '../../../../common/containers/alerts/use_ale import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { useExpandSection } from '../hooks/use_expand_section'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; jest.mock('../../../../common/containers/alerts/use_alert_prevalence'); @@ -96,6 +97,11 @@ jest.mock('../hooks/use_fetch_threat_intelligence'); jest.mock('../../shared/hooks/use_prevalence'); +const mockUseTourContext = useTourContext as jest.Mock; +jest.mock('../../../../common/components/guided_onboarding_tour', () => ({ + useTourContext: jest.fn().mockReturnValue({ activeStep: 1, isTourShown: jest.fn(() => true) }), +})); + const renderInsightsSection = (contextValue: RightPanelContext) => render( @@ -163,6 +169,20 @@ describe('', () => { expect(wrapper.getByTestId(INSIGHTS_CONTENT_TEST_ID)).toBeVisible(); }); + it('should render the component expanded if guided onboarding tour is shown', () => { + (useExpandSection as jest.Mock).mockReturnValue(false); + mockUseTourContext.mockReturnValue({ activeStep: 7, isTourShown: jest.fn(() => true) }); + + const contextValue = { + eventId: 'some_Id', + dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, + getFieldsData: mockGetFieldsData, + } as unknown as RightPanelContext; + + const wrapper = renderInsightsSection(contextValue); + expect(wrapper.getByTestId(INSIGHTS_CONTENT_TEST_ID)).toBeVisible(); + }); + it('should render all children when event kind is signal', () => { (useExpandSection as jest.Mock).mockReturnValue(true); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx index 831124c5539e2..7ea4b67734ae4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx @@ -18,6 +18,11 @@ import { ExpandableSection } from './expandable_section'; import { useRightPanelContext } from '../context'; import { getField } from '../../shared/utils'; import { EventKind } from '../../shared/constants/event_kinds'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; +import { + AlertsCasesTourSteps, + SecurityStepId, +} from '../../../../common/components/guided_onboarding_tour/tour_config'; const KEY = 'insights'; @@ -28,7 +33,12 @@ export const InsightsSection = memo(() => { const { getFieldsData } = useRightPanelContext(); const eventKind = getField(getFieldsData('event.kind')); - const expanded = useExpandSection({ title: KEY, defaultValue: false }); + const { activeStep, isTourShown } = useTourContext(); + const isGuidedOnboardingTourShown = + isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase; + + const expanded = + useExpandSection({ title: KEY, defaultValue: false }) || isGuidedOnboardingTourShown; return ( render( @@ -50,10 +56,11 @@ describe('', () => { services: { ...mockedUseKibana.services, storage: storageMock, + cases: mockCasesContract, }, }); (useIsTimelineFlyoutOpen as jest.Mock).mockReturnValue(false); - + (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => false) }); storageMock.clear(); }); @@ -82,7 +89,19 @@ describe('', () => { expect(queryByText('Next')).not.toBeInTheDocument(); }); - it('should not render tour for non-alerts', () => { + it('should not render tour when guided onboarding tour is active', () => { + (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => true) }); + const { queryByText, queryByTestId } = renderRightPanelTour({ + ...mockContextValue, + getFieldsData: () => '', + }); + + expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument(); + expect(queryByText('Next')).not.toBeInTheDocument(); + }); + + it('should not render tour when case modal is open', () => { + mockUseIsAddToCaseOpen.mockReturnValue(true); const { queryByText, queryByTestId } = renderRightPanelTour({ ...mockContextValue, getFieldsData: () => '', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx index 5003139a92577..9fd7219007dd8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx @@ -20,18 +20,32 @@ import { } from '../../shared/constants/panel_keys'; import { EventKind } from '../../shared/constants/event_kinds'; import { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour/tour'; +import { SecurityStepId } from '../../../../common/components/guided_onboarding_tour/tour_config'; +import { useKibana } from '../../../../common/lib/kibana'; /** * Guided tour for the right panel in details flyout */ export const RightPanelTour = memo(() => { + const { useIsAddToCaseOpen } = useKibana().services.cases.hooks; + + const casesFlyoutExpanded = useIsAddToCaseOpen(); + + const { isTourShown: isGuidedOnboardingTourShown } = useTourContext(); + const { openLeftPanel, openRightPanel } = useExpandableFlyoutApi(); const { eventId, indexName, scopeId, isPreview, getFieldsData } = useRightPanelContext(); const eventKind = getField(getFieldsData('event.kind')); const isAlert = eventKind === EventKind.signal; const isTimelineFlyoutOpen = useIsTimelineFlyoutOpen(); - const showTour = isAlert && !isPreview && !isTimelineFlyoutOpen; + const showTour = + isAlert && + !isPreview && + !isTimelineFlyoutOpen && + !isGuidedOnboardingTourShown(SecurityStepId.alertsCases) && + !casesFlyoutExpanded; const goToLeftPanel = useCallback(() => { openLeftPanel({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx new file mode 100644 index 0000000000000..81f8e14b8b981 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { render } from '@testing-library/react'; +import { PanelHeader } from './header'; +import { allThreeTabs } from './hooks/use_tabs'; +import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; +import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers'; + +jest.mock('./context', () => ({ + useRightPanelContext: jest.fn().mockReturnValue({ dataFormattedForFieldBrowser: [] }), +})); +jest.mock('../../../timelines/components/side_panel/event_details/helpers', () => ({ + useBasicDataFromDetailsData: jest.fn(), +})); +jest.mock('../../../common/components/guided_onboarding_tour/tour_step', () => ({ + GuidedOnboardingTourStep: jest.fn().mockReturnValue(
), +})); + +jest.mock('./components/alert_header_title', () => ({ + AlertHeaderTitle: jest.fn().mockReturnValue(
), +})); + +jest.mock('./components/event_header_title', () => ({ + EventHeaderTitle: jest.fn().mockReturnValue(
), +})); + +const mockUseBasicDataFromDetailsData = useBasicDataFromDetailsData as jest.Mock; +const mockGuidedOnboardingTourStep = GuidedOnboardingTourStep as unknown as jest.Mock; + +describe('PanelHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render tab name', () => { + mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: false }); + const { getByText } = render( + + ); + expect(GuidedOnboardingTourStep).not.toBeCalled(); + expect(getByText('Overview')).toBeInTheDocument(); + }); + + it('should render event header title when isAlert equals false', () => { + mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: false }); + const { queryByTestId } = render( + + ); + expect(queryByTestId('alert-header')).not.toBeInTheDocument(); + expect(queryByTestId('event-header')).toBeInTheDocument(); + }); + + it('should render alert header title when isAlert equals true', () => { + mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: true }); + const { queryByTestId } = render( + + ); + expect(queryByTestId('alert-header')).toBeInTheDocument(); + expect(queryByTestId('event-header')).not.toBeInTheDocument(); + }); + + it('should render tab name with guided onboarding tour info', () => { + mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: true }); + render( + + ); + expect(mockGuidedOnboardingTourStep.mock.calls[0][0].isTourAnchor).toBe(true); + expect(mockGuidedOnboardingTourStep.mock.calls[0][0].step).toBe(3); + expect(mockGuidedOnboardingTourStep.mock.calls[0][0].tourId).toBe('alertsCases'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx index 70c27e18faa23..b85476d2679fc 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx @@ -7,7 +7,7 @@ import { EuiSpacer, EuiTab } from '@elastic/eui'; import type { FC } from 'react'; -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { RightPanelPaths } from '.'; import type { RightPanelTabType } from './tabs'; import { FlyoutHeader } from '../../shared/components/flyout_header'; @@ -16,6 +16,12 @@ import { AlertHeaderTitle } from './components/alert_header_title'; import { EventHeaderTitle } from './components/event_header_title'; import { useRightPanelContext } from './context'; import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers'; +import { + AlertsCasesTourSteps, + getTourAnchor, + SecurityStepId, +} from '../../../common/components/guided_onboarding_tour/tour_config'; +import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; export interface PanelHeaderProps { /** @@ -38,16 +44,48 @@ export const PanelHeader: FC = memo( const { dataFormattedForFieldBrowser } = useRightPanelContext(); const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); const onSelectedTabChanged = (id: RightPanelPaths) => setSelectedTabId(id); - const renderTabs = tabs.map((tab, index) => ( - onSelectedTabChanged(tab.id)} - isSelected={tab.id === selectedTabId} - key={index} - data-test-subj={tab['data-test-subj']} - > - {tab.name} - - )); + + const tourAnchor = useMemo( + () => + isAlert + ? { + 'tour-step': getTourAnchor( + AlertsCasesTourSteps.reviewAlertDetailsFlyout, + SecurityStepId.alertsCases + ), + } + : {}, + [isAlert] + ); + + const renderTabs = tabs.map((tab, index) => + isAlert && tab.id === 'overview' ? ( + + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + key={index} + data-test-subj={tab['data-test-subj']} + {...tourAnchor} + > + {tab.name} + + + ) : ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + key={index} + data-test-subj={tab['data-test-subj']} + > + {tab.name} + + ) + ); return ( diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx index 9cc2e246d94da..0f59727007703 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx @@ -25,6 +25,8 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); +jest.mock('../../../../common/components/guided_onboarding_tour/tour_step'); + type UseCaseItemsReturn = ReturnType; const defaultCaseItemsReturn: UseCaseItemsReturn = { items: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx index b12fdb34e7f39..e48dbe5215f39 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx @@ -96,6 +96,7 @@ jest.mock( } ); jest.mock('../../../../../detections/components/alerts_table/actions'); +jest.mock('../../../../../common/components/guided_onboarding_tour/tour_step'); const defaultProps = { scopeId: TimelineId.test, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index f64f3cf5fd73d..9d4264dd5b079 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -42,7 +42,7 @@ jest.mock('../../../../../common/components/user_privileges', () => { }), }; }); - +jest.mock('../../../../../common/components/guided_onboarding_tour/tour_step'); jest.mock('../../../../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../../../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 57ba0234f0c78..eb8aa54168461 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -40,6 +40,7 @@ import type { } from '@hello-pangea/dnd'; jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../../../common/components/guided_onboarding_tour/tour_step'); jest.mock( '../../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions' );