Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security solution] Attack discovery tour #182605

Merged
merged 15 commits into from
May 8, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,11 @@ describe('SolutionSideNav', () => {
expect(result.queryByTestId('solutionSideNavPanel')).toBeInTheDocument();
expect(result.getByText('Users')).toBeInTheDocument();
});

it('should call onMount when function is provided', () => {
const onMount = jest.fn();
renderNav({ onMount });
expect(onMount).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiListGroup,
EuiFlexGroup,
Expand Down Expand Up @@ -50,6 +50,7 @@ export interface SolutionSideNavProps {
* e.g.: usageCollection?.reportUiCounter?.bind(null, appId)
* */
tracker?: Tracker;
onMount?: () => void;
}
type ActivePanelNav = string | null;
/**
Expand All @@ -62,6 +63,7 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
panelBottomOffset,
panelTopOffset,
tracker,
onMount,
}) {
const isMobileSize = useIsWithinBreakpoints(['xs', 's']);

Expand All @@ -73,6 +75,10 @@ export const SolutionSideNav: React.FC<SolutionSideNavProps> = React.memo(functi
setActivePanelNavId(id);
};

useEffect(() => {
if (onMount) onMount();
}, [onMount]);

const onClosePanelNav = useCallback(() => {
activePanelNavIdRef.current = null;
setActivePanelNavId(null);
Expand Down Expand Up @@ -290,6 +296,7 @@ const SolutionSideNavItem: React.FC<SolutionSideNavItemProps> = React.memo(
className={itemClassNames}
color="text"
size="s"
id={`solutionSideNavItemLink-${id}`}
data-test-subj={`solutionSideNavItemLink-${id}`}
/>
</EuiListGroup>
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ export const NEW_FEATURES_TOUR_STORAGE_KEYS = {
TIMELINES: 'securitySolution.security.timelineFlyoutHeader.saveTimelineTour',
TIMELINE: 'securitySolution.timeline.newFeaturesTour.v8.12',
FLYOUT: 'securitySolution.documentDetails.newFeaturesTour.v8.14',
ATTACK_DISCOVERY: 'securitySolution.attackDiscovery.newFeaturesTour.v8.14',
};

export const RULE_DETAILS_EXECUTION_LOG_TABLE_SHOW_METRIC_COLUMNS_STORAGE_KEY =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
* 2.0.
*/

import React, { type ReactNode, useMemo } from 'react';
import React, { type ReactNode, useMemo, useState, useCallback } from 'react';
import styled from 'styled-components';
import { EuiThemeProvider, useEuiTheme, type EuiThemeComputed } from '@elastic/eui';
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { AttackDiscoveryTour } from '../../../attack_discovery/tour';
import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state';
import { SecuritySolutionFlyout, TimelineFlyout } from '../../../flyout';
import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation';
Expand Down Expand Up @@ -55,7 +56,11 @@ export type SecuritySolutionTemplateWrapperProps = Omit<KibanaPageTemplateProps,

export const SecuritySolutionTemplateWrapper: React.FC<SecuritySolutionTemplateWrapperProps> =
React.memo(({ children, ...rest }) => {
const solutionNavProps = useSecuritySolutionNavigation();
const [didMount, setDidMount] = useState(false);
const onMount = useCallback(() => {
setDidMount(true);
}, []);
const solutionNavProps = useSecuritySolutionNavigation(onMount);
const [isTimelineBottomBarVisible] = useShowTimeline();
const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []);
const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) =>
Expand Down Expand Up @@ -102,6 +107,8 @@ export const SecuritySolutionTemplateWrapper: React.FC<SecuritySolutionTemplateW
{children}
<SecuritySolutionFlyout />
</ExpandableFlyoutProvider>

{didMount && <AttackDiscoveryTour />}
</KibanaPageTemplate.Section>
{isTimelineBottomBarVisible && (
<KibanaPageTemplate.BottomBar data-test-subj="timeline-bottom-bar-container">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* 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 { useIsElementMounted } from '../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted';
import { render, screen } from '@testing-library/react';
import {
createMockStore,
createSecuritySolutionStorageMock,
TestProviders,
} from '../../common/mock';
import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__';
import { useKibana } from '../../common/lib/kibana';
import { AttackDiscoveryTour } from '.';
import { ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS } from './step_config';
import { NEW_FEATURES_TOUR_STORAGE_KEYS, SecurityPageName } from '../../../common/constants';
import type { RouteSpyState } from '../../common/utils/route/types';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';

const mockRouteSpy: RouteSpyState = {
pageName: SecurityPageName.overview,
detailName: undefined,
tabName: undefined,
search: '',
pathName: '/',
};
jest.mock(
'../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted'
);
jest.mock('../../common/lib/kibana');
jest.mock('../../common/utils/route/use_route_spy');
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
EuiTourStep: () => <div data-test-subj="attackDiscovery-tour-step-1" />,
};
});
const mockedUseKibana = mockUseKibana();

const { storage: storageMock } = createSecuritySolutionStorageMock();
const mockStore = createMockStore(undefined, undefined, undefined, storageMock);

const TestComponent = () => {
return (
<TestProviders store={mockStore}>
<div id={ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS.NAV_LINK} />
<AttackDiscoveryTour />
</TestProviders>
);
};

describe('Attack discovery tour', () => {
beforeAll(() => {
(useIsElementMounted as jest.Mock).mockReturnValue(true);
(useRouteSpy as jest.Mock).mockReturnValue([mockRouteSpy]);
});

beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
storage: storageMock,
},
});

storageMock.clear();
});
afterEach(() => {
jest.clearAllMocks();
});

it('should not render tour step 1 when element is not mounted', () => {
(useIsElementMounted as jest.Mock).mockReturnValueOnce(false);
render(<TestComponent />);
expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull();
});

it('should not render any tour steps when tour is not activated', () => {
storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
currentTourStep: 1,
isTourActive: false,
});
render(<TestComponent />);
expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull();
expect(screen.queryByTestId('attackDiscovery-tour-step-2')).toBeNull();
});

it('should not render any tour steps when tour is on step 2 and page is not attack discovery', () => {
storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
currentTourStep: 2,
isTourActive: true,
});
const { debug } = render(<TestComponent />);
expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull();
debug();
});

it('should render tour step 1 when element is mounted', async () => {
const { getByTestId } = render(<TestComponent />);

expect(getByTestId('attackDiscovery-tour-step-1')).toBeInTheDocument();
});

it('should render tour video when tour is on step 2 and page is attack discovery', () => {
(useRouteSpy as jest.Mock).mockReturnValue([
{ ...mockRouteSpy, pageName: SecurityPageName.attackDiscovery },
]);
storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
currentTourStep: 2,
isTourActive: true,
});
const { getByTestId } = render(<TestComponent />);
expect(screen.queryByTestId('attackDiscovery-tour-step-1')).toBeNull();
expect(getByTestId('attackDiscovery-tour-step-2')).toBeInTheDocument();
});

it('should advance to tour step 2 when page is attack discovery', () => {
(useRouteSpy as jest.Mock).mockReturnValue([
{ ...mockRouteSpy, pageName: SecurityPageName.attackDiscovery },
]);
storageMock.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
currentTourStep: 1,
isTourActive: true,
});
render(<TestComponent />);
expect(
storageMock.get(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY).currentTourStep
).toEqual(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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.
*/

/*
* The attack discovery tour for 8.14
*
* */

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiButton, EuiButtonEmpty, EuiTourStep } from '@elastic/eui';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { VideoToast } from './video_toast';
import { useIsElementMounted } from '../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted';
import { NEW_FEATURES_TOUR_STORAGE_KEYS, SecurityPageName } from '../../../common/constants';
import { useKibana, useNavigation } from '../../common/lib/kibana';
import { attackDiscoveryTourStepOne, tourConfig } from './step_config';
import * as i18n from './translations';

interface TourState {
currentTourStep: number;
isTourActive: boolean;
}

const AttackDiscoveryTourComp = () => {
const {
services: { storage },
} = useKibana();

const { navigateTo } = useNavigation();
const [{ pageName }] = useRouteSpy();
const [tourState, setTourState] = useState<TourState>(
storage.get(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY) ?? tourConfig
);

const advanceToVideoStep = useCallback(() => {
setTourState((prev) => {
storage.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
...prev,
currentTourStep: 2,
});
return {
...prev,
currentTourStep: 2,
};
});
}, [storage]);

useEffect(() => {
if (tourState.isTourActive && pageName === SecurityPageName.attackDiscovery) {
advanceToVideoStep();
}
}, [advanceToVideoStep, pageName, tourState.isTourActive]);

const finishTour = useCallback(() => {
setTourState((prev) => {
storage.set(NEW_FEATURES_TOUR_STORAGE_KEYS.ATTACK_DISCOVERY, {
...prev,
isTourActive: false,
});
return {
...prev,
isTourActive: false,
};
});
}, [storage]);

const navigateToAttackDiscovery = useCallback(() => {
navigateTo({
deepLinkId: SecurityPageName.attackDiscovery,
});
}, [navigateTo]);

const nextStep = useCallback(() => {
if (tourState.currentTourStep === 1) {
navigateToAttackDiscovery();
advanceToVideoStep();
}
}, [tourState.currentTourStep, navigateToAttackDiscovery, advanceToVideoStep]);

const footerAction = useMemo(
() => [
// if exit, set tour to the video step without navigating to the page
<EuiButtonEmpty size="s" color="text" onClick={advanceToVideoStep}>
{i18n.ATTACK_DISCOVERY_TOUR_EXIT}
</EuiButtonEmpty>,
// if next, set tour to the video step and navigate to the page
<EuiButton color="success" size="s" onClick={nextStep}>
{i18n.ATTACK_DISCOVERY_TRY_IT}
</EuiButton>,
],
[advanceToVideoStep, nextStep]
);

const isElementAtCurrentStepMounted = useIsElementMounted(attackDiscoveryTourStepOne?.anchor);

if (
!tourState.isTourActive ||
(tourState.currentTourStep === 1 && !isElementAtCurrentStepMounted)
) {
return null;
}

return tourState.currentTourStep === 1 ? (
<EuiTourStep
anchor={`#${attackDiscoveryTourStepOne.anchor}`}
content={attackDiscoveryTourStepOne.content}
footerAction={footerAction}
isStepOpen
maxWidth={450}
onFinish={advanceToVideoStep}
panelProps={{
'data-test-subj': `attackDiscovery-tour-step-1`,
}}
repositionOnScroll
step={1}
stepsTotal={1}
title={attackDiscoveryTourStepOne.title}
/>
) : pageName === SecurityPageName.attackDiscovery ? (
<VideoToast onClose={finishTour} />
) : null;
};

export const AttackDiscoveryTour = React.memo(AttackDiscoveryTourComp);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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 * as i18n from './translations';

export const ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS = {
NAV_LINK: 'solutionSideNavItemLink-attack_discovery',
};

export const attackDiscoveryTourStepOne = {
title: i18n.ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_TITLE,
content: i18n.ATTACK_DISCOVERY_TOUR_ATTACK_DISCOVERY_DESC,
anchor: ATTACK_DISCOVERY_TOUR_CONFIG_ANCHORS.NAV_LINK,
};

export const tourConfig = {
currentTourStep: 1,
isTourActive: true,
};
Loading