[Security solution] Guided onboarding, alerts & cases design updates (#144249)

This commit is contained in:
Steph Milovic 2022-11-09 14:13:10 -07:00 committed by GitHub
parent 47f38bc3df
commit b721fdcf42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 858 additions and 219 deletions

1
.github/CODEOWNERS vendored
View file

@ -486,6 +486,7 @@
/x-pack/plugins/security_solution/cypress/tasks/network @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/cypress/upgrade_e2e/threat_hunting/cases @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/charts @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_page @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_section @elastic/security-threat-hunting-explore

View file

@ -10,6 +10,7 @@ import styled, { createGlobalStyle } from 'styled-components';
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
import { QueryClientProvider } from '@tanstack/react-query';
import type { CasePostRequest } from '../../../../common/api';
import * as i18n from '../translations';
import type { Case } from '../../../../common/ui/types';
import { CreateCaseForm } from '../form';
@ -26,6 +27,7 @@ export interface CreateCaseFlyoutProps {
onSuccess?: (theCase: Case) => Promise<void>;
attachments?: CaseAttachmentsWithoutOwner;
headerContent?: React.ReactNode;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}
const StyledFlyout = styled(EuiFlyout)`
@ -72,7 +74,7 @@ const FormWrapper = styled.div`
`;
export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
({ afterCaseCreated, onClose, onSuccess, attachments, headerContent }) => {
({ afterCaseCreated, attachments, headerContent, initialValue, onClose, onSuccess }) => {
const handleCancel = onClose || function () {};
const handleOnSuccess = onSuccess || async function () {};
@ -81,6 +83,7 @@ export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
<GlobalStyle />
<StyledFlyout
onClose={onClose}
tour-step="create-case-flyout"
data-test-subj="create-case-flyout"
// maskProps is needed in order to apply the z-index to the parent overlay element, not to the flyout only
maskProps={{ className: maskOverlayClassName }}
@ -99,6 +102,7 @@ export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
onCancel={handleCancel}
onSuccess={handleOnSuccess}
withSteps={false}
initialValue={initialValue}
/>
</FormWrapper>
</StyledEuiFlyoutBody>

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { act, render } from '@testing-library/react';
import { act, render, within } from '@testing-library/react';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { NONE_CONNECTOR_ID } from '../../../common/api';
@ -182,4 +182,34 @@ describe('CreateCaseForm', () => {
expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument();
});
it('should not prefill the form when no initialValue provided', () => {
const { getByTestId } = render(
<MockHookWrapperComponent>
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);
const titleInput = within(getByTestId('caseTitle')).getByTestId('input');
const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox');
expect(titleInput).toHaveValue('');
expect(descriptionInput).toHaveValue('');
});
it('should prefill the form when provided with initialValue', () => {
const { getByTestId } = render(
<MockHookWrapperComponent>
<CreateCaseForm
{...casesFormProps}
initialValue={{ title: 'title', description: 'description' }}
/>
</MockHookWrapperComponent>
);
const titleInput = within(getByTestId('caseTitle')).getByTestId('input');
const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox');
expect(titleInput).toHaveValue('title');
expect(descriptionInput).toHaveValue('description');
});
});

View file

@ -23,7 +23,7 @@ import { Tags } from './tags';
import { Connector } from './connector';
import * as i18n from './translations';
import { SyncAlertsToggle } from './sync_alerts_toggle';
import type { ActionConnector } from '../../../common/api';
import type { ActionConnector, CasePostRequest } from '../../../common/api';
import type { Case } from '../../containers/types';
import type { CasesTimelineIntegration } from '../timeline_context';
import { CasesTimelineIntegrationProvider } from '../timeline_context';
@ -70,6 +70,7 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr
) => Promise<void>;
timelineIntegration?: CasesTimelineIntegration;
attachments?: CaseAttachmentsWithoutOwner;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}
const empty: ActionConnector[] = [];
@ -79,6 +80,7 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures();
const { owner } = useCasesContext();
const availableOwners = useAvailableCasesOwners();
const canShowCaseSolutionSelection = !owner.length && availableOwners.length;
@ -181,12 +183,14 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo(
onSuccess,
timelineIntegration,
attachments,
initialValue,
}) => (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<FormContext
afterCaseCreated={afterCaseCreated}
onSuccess={onSuccess}
attachments={attachments}
initialValue={initialValue}
>
<CreateCaseFormFields
connectors={empty}

View file

@ -14,6 +14,7 @@ import { usePostCase } from '../../containers/use_post_case';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import type { Case } from '../../containers/types';
import type { CasePostRequest } from '../../../common/api';
import { CaseSeverity, NONE_CONNECTOR_ID } from '../../../common/api';
import type { UseCreateAttachments } from '../../containers/use_create_attachments';
import { useCreateAttachments } from '../../containers/use_create_attachments';
@ -44,6 +45,7 @@ interface Props {
children?: JSX.Element | JSX.Element[];
onSuccess?: (theCase: Case) => Promise<void>;
attachments?: CaseAttachmentsWithoutOwner;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}
export const FormContext: React.FC<Props> = ({
@ -51,6 +53,7 @@ export const FormContext: React.FC<Props> = ({
children,
onSuccess,
attachments,
initialValue,
}) => {
const { data: connectors = [], isLoading: isLoadingConnectors } = useGetConnectors();
const { owner, appId } = useCasesContext();
@ -128,7 +131,7 @@ export const FormContext: React.FC<Props> = ({
);
const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
defaultValue: { ...initialCaseValue, ...initialValue },
options: { stripEmptyFields: false },
schema,
onSubmit: submitCase,

View file

@ -16,6 +16,7 @@ const SubmitCaseButtonComponent: React.FC = () => {
return (
<EuiButton
tour-step="create-case-submit"
data-test-subj="create-case-submit"
fill
iconType="plusInCircle"

View file

@ -0,0 +1,93 @@
/*
* 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 { Cases } from '.';
import { Router } from 'react-router-dom';
import { render } from '@testing-library/react';
import { TestProviders } from '../../common/mock';
import { useTourContext } from '../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../common/components/guided_onboarding_tour/tour_config';
jest.mock('../../common/components/guided_onboarding_tour');
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
pathname: '/network',
search: '',
state: '',
hash: '',
};
const mockHistory = {
length: 2,
location,
action: pop,
push: jest.fn(),
replace: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
block: jest.fn(),
createHref: jest.fn(),
listen: jest.fn(),
};
describe('cases page in security', () => {
const endTourStep = jest.fn();
beforeEach(() => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep,
isTourShown: () => true,
});
jest.clearAllMocks();
});
it('calls endTour on cases details page when SecurityStepId.alertsCases tour is active and step is AlertsCasesTourSteps.viewCase', () => {
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).toHaveBeenCalledWith(SecurityStepId.alertsCases);
});
it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is not active', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep,
isTourShown: () => false,
});
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).not.toHaveBeenCalled();
});
it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is active and step is not AlertsCasesTourSteps.viewCase', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.expandEvent,
incrementStep: () => null,
endTourStep,
isTourShown: () => true,
});
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).not.toHaveBeenCalled();
});
});

View file

@ -5,9 +5,14 @@
* 2.0.
*/
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common';
import { useTourContext } from '../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../common/components/guided_onboarding_tour/tour_config';
import { TimelineId } from '../../../common/types/timeline';
import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to';
@ -91,6 +96,16 @@ const CaseContainerComponent: React.FC = () => {
}, [dispatch]);
const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
const { activeStep, endTourStep, isTourShown } = useTourContext();
const isTourActive = useMemo(
() => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases),
[activeStep, isTourShown]
);
useEffect(() => {
if (isTourActive) endTourStep(SecurityStepId.alertsCases);
}, [endTourStep, isTourActive]);
return (
<SecuritySolutionPageWrapper noPadding>

View file

@ -25,7 +25,11 @@ import type { SearchHit } from '../../../../common/search_strategy';
import { getMitreComponentParts } from '../../../detections/mitre/get_mitre_threat_component';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../top_n/helpers';
import { getTourAnchor, SecurityStepId } from '../guided_onboarding_tour/tour_config';
import {
AlertsCasesTourSteps,
getTourAnchor,
SecurityStepId,
} from '../guided_onboarding_tour/tour_config';
import type { AlertRawEventData } from './osquery_tab';
import { useOsqueryTab } from './osquery_tab';
import { EventFieldsBrowser } from './event_fields_browser';
@ -448,8 +452,8 @@ const EventDetailsComponent: React.FC<Props> = ({
return (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={3}
stepId={SecurityStepId.alertsCases}
step={AlertsCasesTourSteps.reviewAlertDetailsFlyout}
tourId={SecurityStepId.alertsCases}
>
<StyledEuiTabbedContent
{...tourAnchor}

View file

@ -27,6 +27,7 @@ interface Props {
renderContent: () => ReactNode;
extraAction?: EuiAccordionProps['extraAction'];
onToggle?: EuiAccordionProps['onToggle'];
forceState?: EuiAccordionProps['forceState'];
}
/**
@ -34,7 +35,7 @@ interface Props {
* It wraps logic and custom styling around the loading, error and success states of an insight section.
*/
export const InsightAccordion = React.memo<Props>(
({ prefix, state, text, renderContent, onToggle = noop, extraAction }) => {
({ prefix, state, text, renderContent, onToggle = noop, extraAction, forceState }) => {
const accordionId = useGeneratedHtmlId({ prefix });
switch (state) {
@ -62,11 +63,14 @@ export const InsightAccordion = React.memo<Props>(
// The accordion can display the content now
return (
<StyledAccordion
tour-step={`${prefix}-accordion`}
data-test-subj={`${prefix}-accordion`}
id={accordionId}
buttonContent={text}
onToggle={onToggle}
paddingSize="l"
extraAction={extraAction}
forceState={forceState}
>
{renderContent()}
</StyledAccordion>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
@ -14,10 +14,12 @@ import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { RelatedCases } from './related_cases';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { CASES_LOADING, CASES_COUNT } from './translations';
import { useTourContext } from '../../guided_onboarding_tour';
import { AlertsCasesTourSteps } from '../../guided_onboarding_tour/tour_config';
const mockedUseKibana = mockUseKibana();
const mockGetRelatedCases = jest.fn();
jest.mock('../../guided_onboarding_tour');
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
@ -40,70 +42,76 @@ jest.mock('../../../lib/kibana', () => {
});
const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a';
const scrollToMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollToMock;
describe('Related Cases', () => {
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep: () => null,
isTourShown: () => false,
});
jest.clearAllMocks();
});
describe('When user does not have cases read permissions', () => {
test('should not show related cases when user does not have permissions', () => {
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(noCasesPermissions());
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
});
test('should not show related cases when user does not have permissions', async () => {
await act(async () => {
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
});
expect(screen.queryByText('cases')).toBeNull();
});
});
describe('When user does have case read permissions', () => {
beforeEach(() => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
});
describe('When related cases are loading', () => {
test('should show the loading message', () => {
test('Should show the loading message', async () => {
await act(async () => {
mockGetRelatedCases.mockReturnValue([]);
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
expect(screen.getByText(CASES_LOADING)).toBeInTheDocument();
});
});
describe('When related cases are unable to be retrieved', () => {
test('should show 0 related cases when there are none', async () => {
test('Should show 0 related cases when there are none', async () => {
await act(async () => {
mockGetRelatedCases.mockReturnValue([]);
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument();
});
});
expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument();
});
describe('When 1 related case is retrieved', () => {
test('should show 1 related case', async () => {
test('Should show 1 related case', async () => {
await act(async () => {
mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument();
expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case');
});
});
expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument();
expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case');
});
describe('When 2 related cases are retrieved', () => {
test('should show 2 related cases', async () => {
test('Should show 2 related cases', async () => {
await act(async () => {
mockGetRelatedCases.mockReturnValue([
{ id: '789', title: 'Test Case 1' },
{ id: '456', title: 'Test Case 2' },
@ -113,15 +121,48 @@ describe('Related Cases', () => {
<RelatedCases eventId={eventId} />
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument();
const cases = screen.getAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
expect(cases[0]).toHaveTextContent('Test Case 1');
expect(cases[1]).toHaveTextContent('Test Case 2');
});
});
expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument();
const cases = screen.getAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
expect(cases[0]).toHaveTextContent('Test Case 1');
expect(cases[1]).toHaveTextContent('Test Case 2');
});
test('Should not open the related cases accordion when isTourActive=false', async () => {
mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
await act(async () => {
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
});
expect(scrollToMock).not.toHaveBeenCalled();
expect(
screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen')
).toBe(false);
});
test('Should automatically open the related cases accordion when isTourActive=true', async () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep: () => null,
isTourShown: () => true,
});
mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
await act(async () => {
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
});
expect(scrollToMock).toHaveBeenCalled();
expect(
screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen')
).toBe(true);
});
});
});

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { AlertsCasesTourSteps, SecurityStepId } from '../../guided_onboarding_tour/tour_config';
import { useTourContext } from '../../guided_onboarding_tour';
import { useKibana, useToasts } from '../../../lib/kibana';
import { CaseDetailsLink } from '../../links';
import { APP_ID } from '../../../../../common/constants';
@ -33,27 +35,49 @@ export const RelatedCases = React.memo<Props>(({ eventId }) => {
const [relatedCases, setRelatedCases] = useState<RelatedCaseList | undefined>(undefined);
const [hasError, setHasError] = useState<boolean>(false);
const { activeStep, isTourShown } = useTourContext();
const isTourActive = useMemo(
() => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases),
[activeStep, isTourShown]
);
const renderContent = useCallback(() => renderCaseContent(relatedCases), [relatedCases]);
const getRelatedCases = useCallback(async () => {
let relatedCaseList: RelatedCaseList = [];
try {
if (eventId) {
relatedCaseList =
(await cases.api.getRelatedCases(eventId, {
owner: APP_ID,
})) ?? [];
}
} catch (error) {
setHasError(true);
toasts.addWarning(CASES_ERROR_TOAST(error));
}
setRelatedCases(relatedCaseList);
}, [eventId, cases.api, toasts]);
const [shouldFetch, setShouldFetch] = useState<boolean>(false);
useEffect(() => {
getRelatedCases();
}, [eventId, getRelatedCases]);
if (!shouldFetch) {
return;
}
let ignore = false;
const fetch = async () => {
let relatedCaseList: RelatedCaseList = [];
try {
if (eventId) {
relatedCaseList =
(await cases.api.getRelatedCases(eventId, {
owner: APP_ID,
})) ?? [];
}
} catch (error) {
if (!ignore) {
setHasError(true);
}
toasts.addWarning(CASES_ERROR_TOAST(error));
}
if (!ignore) {
setRelatedCases(relatedCaseList);
setShouldFetch(false);
}
};
fetch();
return () => {
ignore = true;
};
}, [cases.api, eventId, shouldFetch, toasts]);
useEffect(() => {
setShouldFetch(true);
}, [eventId]);
let state: InsightAccordionState = 'loading';
if (hasError) {
@ -68,6 +92,7 @@ export const RelatedCases = React.memo<Props>(({ eventId }) => {
state={state}
text={getTextFromState(state, relatedCases?.length)}
renderContent={renderContent}
forceState={isTourActive ? 'open' : undefined}
/>
);
});
@ -95,7 +120,7 @@ function renderCaseContent(relatedCases: RelatedCaseList = []) {
id && title ? (
<span key={id}>
{' '}
<CaseDetailsLink detailName={id} title={title}>
<CaseDetailsLink detailName={id} title={title} index={index}>
{title}
</CaseDetailsLink>
{relatedCases[index + 1] ? ',' : ''}

View file

@ -1,5 +1,4 @@
## Security Guided Onboarding Tour
This work required some creativity for reasons. Allow me to explain some weirdness
The [`EuiTourStep`](https://elastic.github.io/eui/#/display/tour) component needs an **anchor** to attach on in the DOM. This can be defined in 2 ways:
```
@ -47,7 +46,7 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s
<img width="1332" alt="1" src="https://user-images.githubusercontent.com/6935300/197848717-47c1959d-5dd5-4d72-a81d-786987000360.png">
The component for this anchor is `RenderCellValue` which returns `DefaultCellRenderer`. We wrap `DefaultCellRenderer` with `GuidedOnboardingTourStep`, passing `step={1} stepId={SecurityStepId.alertsCases}` to indicate the step. Since there are many other iterations of this component on the page, we also need to pass the `isTourAnchor` property to determine which of these components should be the anchor. In the code, this looks something like:
The component for this anchor is `RenderCellValue` which returns `DefaultCellRenderer`. We wrap `DefaultCellRenderer` with `GuidedOnboardingTourStep`, passing `step={AlertsCasesTourSteps.pointToAlertName} tourId={SecurityStepId.alertsCases}` to indicate the step. Since there are many other iterations of this component on the page, we also need to pass the `isTourAnchor` property to determine which of these components should be the anchor. In the code, this looks something like:
```
export const RenderCellValue = (props) => {
@ -63,8 +62,8 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s
return (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={1}
stepId={SecurityStepId.alertsCases}
step={AlertsCasesTourSteps.pointToAlertName}
tourId={SecurityStepId.alertsCases}
>
<DefaultCellRenderer {...props} />
</GuidedOnboardingTourStep>
@ -87,14 +86,13 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s
defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`,
}
),
anchor: `[data-test-subj="create-case-flyout"]`,
anchor: `[tour-step="create-case-flyout"]`,
anchorPosition: 'leftUp',
dataTestSubj: getTourAnchor(5, SecurityStepId.alertsCases),
hideNextButton: true,
}
```
Notice that the **anchor prop is defined** as `[data-test-subj="create-case-flyout"]` in the step 5 config. There is also a `hideNextButton` boolean utilized here.
Notice that the **anchor prop is defined** as `[tour-step="create-case-flyout"]` in the step 5 config.
As you can see pictured below, the tour step anchor is the create case flyout and the next button is hidden.
<img width="1336" alt="5" src="https://user-images.githubusercontent.com/6935300/197848670-09a6fa58-7417-4c9b-9be0-fb58224c2dc8.png">
@ -110,7 +108,7 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s
headerContent: (
// isTourAnchor=true no matter what in order to
// force active guide step outside of security solution (cases)
<GuidedOnboardingTourStep isTourAnchor step={5} stepId={SecurityStepId.alertsCases} />
<GuidedOnboardingTourStep isTourAnchor step={AlertsCasesTourSteps.createCase} tourId={SecurityStepId.alertsCases} />
),
}
: {}),
@ -121,9 +119,9 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s
```
export interface TourContextValue {
activeStep: number;
endTourStep: (stepId: SecurityStepId) => void;
incrementStep: (stepId: SecurityStepId, step?: number) => void;
isTourShown: (stepId: SecurityStepId) => boolean;
endTourStep: (tourId: SecurityStepId) => void;
incrementStep: (tourId: SecurityStepId, step?: number) => void;
isTourShown: (tourId: SecurityStepId) => boolean;
}
```
When the tour step does not have a next button, the anchor component will need to call `incrementStep` after an action is taken. For example, in `SecurityStepId.alertsCases` step 4, the user needs to click the "Add to case" button to advance the tour.

View file

@ -0,0 +1,34 @@
/*
* 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 { CasesTourSteps } from './cases_tour_steps';
import { AlertsCasesTourSteps } from './tour_config';
import { TestProviders } from '../../mock';
jest.mock('./tour_step', () => ({
GuidedOnboardingTourStep: jest
.fn()
.mockImplementation(({ step, onClick }: { onClick: () => void; step: number }) => (
<button type="submit" data-test-subj={`step-${step}`} onClick={onClick} />
)),
}));
describe('cases tour steps', () => {
it('Mounts with AlertsCasesTourSteps.createCase step active', () => {
const { getByTestId, queryByTestId } = render(<CasesTourSteps />, { wrapper: TestProviders });
expect(getByTestId(`step-${AlertsCasesTourSteps.createCase}`)).toBeInTheDocument();
expect(queryByTestId(`step-${AlertsCasesTourSteps.submitCase}`)).not.toBeInTheDocument();
});
it('On click next, AlertsCasesTourSteps.submitCase step active', () => {
const { getByTestId, queryByTestId } = render(<CasesTourSteps />, { wrapper: TestProviders });
getByTestId(`step-${AlertsCasesTourSteps.createCase}`).click();
expect(getByTestId(`step-${AlertsCasesTourSteps.submitCase}`)).toBeInTheDocument();
expect(queryByTestId(`step-${AlertsCasesTourSteps.createCase}`)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { AlertsCasesTourSteps, SecurityStepId } from './tour_config';
import { GuidedOnboardingTourStep } from './tour_step';
const getSubmitButton = (): HTMLElement | null =>
document.querySelector(`[tour-step="create-case-submit"]`);
export const CasesTourSteps = () => {
const [activeStep, setActiveStep] = useState(AlertsCasesTourSteps.createCase);
const scrollToSubmitButton = useCallback(() => {
getSubmitButton()?.scrollIntoView();
}, []);
const onClick = useCallback(() => {
setActiveStep(AlertsCasesTourSteps.submitCase);
scrollToSubmitButton();
setTimeout(() => {
// something is resetting focus to close flyout button
getSubmitButton()?.focus();
}, 500);
}, [scrollToSubmitButton]);
return (
<>
{activeStep === AlertsCasesTourSteps.createCase && (
<GuidedOnboardingTourStep
onClick={onClick}
step={AlertsCasesTourSteps.createCase}
tourId={SecurityStepId.alertsCases}
/>
)}
{activeStep === AlertsCasesTourSteps.submitCase && (
<GuidedOnboardingTourStep
step={AlertsCasesTourSteps.submitCase}
tourId={SecurityStepId.alertsCases}
/>
)}
</>
);
};

View file

@ -41,8 +41,8 @@ describe('useTourContext', () => {
jest.clearAllMocks();
});
// @ts-ignore
const stepIds = Object.values(SecurityStepId);
describe.each(stepIds)('%s', (stepId) => {
const tourIds = [SecurityStepId.alertsCases];
describe.each(tourIds)('%s', (tourId: SecurityStepId) => {
it('if guidedOnboardingApi?.isGuideStepActive$ is false, isTourShown should be false', () => {
(useKibana as jest.Mock).mockReturnValue({
services: {
@ -56,22 +56,22 @@ describe('useTourContext', () => {
const { result } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
expect(result.current.isTourShown(stepId)).toBe(false);
expect(result.current.isTourShown(tourId)).toBe(false);
});
it('if guidedOnboardingApi?.isGuideStepActive$ is true, isTourShown should be true', () => {
const { result } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
expect(result.current.isTourShown(stepId)).toBe(true);
expect(result.current.isTourShown(tourId)).toBe(true);
});
it('endTourStep calls completeGuideStep with correct stepId', async () => {
it('endTourStep calls completeGuideStep with correct tourId', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
await waitForNextUpdate();
result.current.endTourStep(stepId);
expect(mockCompleteGuideStep).toHaveBeenCalledWith('security', stepId);
result.current.endTourStep(tourId);
expect(mockCompleteGuideStep).toHaveBeenCalledWith('security', tourId);
});
});
it('activeStep is initially 1', () => {
@ -80,19 +80,41 @@ describe('useTourContext', () => {
});
expect(result.current.activeStep).toBe(1);
});
it('increment step properly increments for each stepId, and if attempted to increment beyond length of tour config steps resets activeStep to 1', async () => {
it('incrementStep properly increments for each tourId, and if attempted to increment beyond length of tour config steps resets activeStep to 1', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
await waitForNextUpdate();
const stepCount = securityTourConfig[stepId].length;
const stepCount = securityTourConfig[tourId].length;
for (let i = 0; i < stepCount - 1; i++) {
result.current.incrementStep(stepId);
result.current.incrementStep(tourId);
}
const lastStep = stepCount ? stepCount : 1;
expect(result.current.activeStep).toBe(lastStep);
result.current.incrementStep(stepId);
result.current.incrementStep(tourId);
expect(result.current.activeStep).toBe(1);
});
});
it('setStep sets activeStep to step number argument', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
await waitForNextUpdate();
result.current.setStep(tourId, 7);
expect(result.current.activeStep).toBe(7);
});
});
it('does not setStep sets activeStep to non-existing step number', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() => useTourContext(), {
wrapper: TourContextProvider,
});
await waitForNextUpdate();
result.current.setStep(tourId, 88);
expect(result.current.activeStep).toBe(1);
});
});

View file

@ -12,15 +12,17 @@ import useObservable from 'react-use/lib/useObservable';
import { catchError, of, timeout } from 'rxjs';
import { useLocation } from 'react-router-dom';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { isDetectionsPath } from '../../../helpers';
import { isTourPath } from '../../../helpers';
import { useKibana } from '../../lib/kibana';
import type { AlertsCasesTourSteps } from './tour_config';
import { securityTourConfig, SecurityStepId } from './tour_config';
export interface TourContextValue {
activeStep: number;
endTourStep: (stepId: SecurityStepId) => void;
incrementStep: (stepId: SecurityStepId) => void;
isTourShown: (stepId: SecurityStepId) => boolean;
endTourStep: (tourId: SecurityStepId) => void;
incrementStep: (tourId: SecurityStepId) => void;
isTourShown: (tourId: SecurityStepId) => boolean;
setStep: (tourId: SecurityStepId, step: AlertsCasesTourSteps) => void;
}
const initialState: TourContextValue = {
@ -28,6 +30,7 @@ const initialState: TourContextValue = {
endTourStep: () => {},
incrementStep: () => {},
isTourShown: () => false,
setStep: () => {},
};
const TourContext = createContext<TourContextValue>(initialState);
@ -60,21 +63,18 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild })
[isRulesTourActive, isAlertsCasesTourActive]
);
const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]);
const isTourShown = useCallback((tourId: SecurityStepId) => tourStatus[tourId], [tourStatus]);
const [activeStep, _setActiveStep] = useState<number>(1);
const incrementStep = useCallback((stepId: SecurityStepId) => {
const incrementStep = useCallback((tourId: SecurityStepId) => {
_setActiveStep(
(prevState) => (prevState >= securityTourConfig[stepId].length ? 0 : prevState) + 1
(prevState) => (prevState >= securityTourConfig[tourId].length ? 0 : prevState) + 1
);
}, []);
// TODO: @Steph figure out if we're allowing user to skip tour or not, implement this if so
// const onSkipTour = useCallback((stepId: SecurityStepId) => {
// // active state means the user is on this step but has not yet begun. so when the user hits skip,
// // the tour will go back to this step until they "re-start it"
// // guidedOnboardingApi.idkSetStepTo(stepId, 'active')
// }, []);
const setStep = useCallback((tourId: SecurityStepId, step: number) => {
if (step <= securityTourConfig[tourId].length) _setActiveStep(step);
}, []);
const [completeStep, setCompleteStep] = useState<null | SecurityStepId>(null);
@ -96,8 +96,8 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild })
};
}, [completeStep, guidedOnboardingApi]);
const endTourStep = useCallback((stepId: SecurityStepId) => {
setCompleteStep(stepId);
const endTourStep = useCallback((tourId: SecurityStepId) => {
setCompleteStep(tourId);
}, []);
const context = {
@ -105,6 +105,7 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild })
endTourStep,
incrementStep,
isTourShown,
setStep,
};
return <TourContext.Provider value={context}>{children}</TourContext.Provider>;
@ -114,11 +115,12 @@ export const TourContextProvider = ({ children }: { children: ReactChild }) => {
const { pathname } = useLocation();
const isTourEnabled = useIsExperimentalFeatureEnabled('guidedOnboarding');
if (isDetectionsPath(pathname) && isTourEnabled) {
return <RealTourContextProvider>{children}</RealTourContextProvider>;
}
const ContextProvider = useMemo(
() => (isTourPath(pathname) && isTourEnabled ? RealTourContextProvider : TourContext.Provider),
[isTourEnabled, pathname]
);
return <TourContext.Provider value={initialState}>{children}</TourContext.Provider>;
return <ContextProvider value={initialState}>{children}</ContextProvider>;
};
export const useTourContext = (): TourContextValue => {

View file

@ -21,11 +21,21 @@ export const enum AlertsCasesTourSteps {
reviewAlertDetailsFlyout = 3,
addAlertToCase = 4,
createCase = 5,
submitCase = 6,
viewCase = 7,
}
export type StepConfig = Pick<
EuiTourStepProps,
'step' | 'content' | 'anchorPosition' | 'title' | 'initialFocus' | 'anchor'
| 'step'
| 'content'
| 'anchorPosition'
| 'title'
| 'ownFocus'
| 'initialFocus'
| 'anchor'
| 'offset'
| 'repositionOnScroll'
> & {
anchor?: ElementTarget;
dataTestSubj: string;
@ -41,10 +51,13 @@ const defaultConfig = {
maxWidth: 360,
offset: 10,
repositionOnScroll: true,
// need both properties below to focus the next button
ownFocus: true,
initialFocus: `[tour-step="nextButton"]`,
};
export const getTourAnchor = (step: number, stepId: SecurityStepId) =>
`tourStepAnchor-${stepId}-${step}`;
export const getTourAnchor = (step: number, tourId: SecurityStepId) =>
`tourStepAnchor-${tourId}-${step}`;
const alertsCasesConfig: StepConfig[] = [
{
@ -79,7 +92,6 @@ const alertsCasesConfig: StepConfig[] = [
),
anchorPosition: 'rightUp',
dataTestSubj: getTourAnchor(AlertsCasesTourSteps.expandEvent, SecurityStepId.alertsCases),
hideNextButton: true,
},
{
...defaultConfig,
@ -101,7 +113,8 @@ const alertsCasesConfig: StepConfig[] = [
anchor: `[tour-step="${getTourAnchor(
AlertsCasesTourSteps.reviewAlertDetailsFlyout,
SecurityStepId.alertsCases
)}"] .euiTabs`,
)}"] span.euiTab__content`,
offset: 20,
anchorPosition: 'leftUp',
dataTestSubj: getTourAnchor(
AlertsCasesTourSteps.reviewAlertDetailsFlyout,
@ -119,7 +132,6 @@ const alertsCasesConfig: StepConfig[] = [
}),
anchorPosition: 'upRight',
dataTestSubj: getTourAnchor(AlertsCasesTourSteps.addAlertToCase, SecurityStepId.alertsCases),
hideNextButton: true,
},
{
...defaultConfig,
@ -133,13 +145,59 @@ const alertsCasesConfig: StepConfig[] = [
defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`,
}
),
anchor: `[data-test-subj="create-case-flyout"]`,
anchor: `[tour-step="create-case-flyout"] label`,
anchorPosition: 'leftUp',
dataTestSubj: getTourAnchor(AlertsCasesTourSteps.createCase, SecurityStepId.alertsCases),
offset: 20,
repositionOnScroll: false,
},
{
...defaultConfig,
step: AlertsCasesTourSteps.submitCase,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.submitCase.tourTitle', {
defaultMessage: `Submit case`,
}),
content: i18n.translate(
'xpack.securitySolution.guided_onboarding.tour.submitCase.tourContent',
{
defaultMessage: `Press Create case to advance the tour.`,
}
),
anchor: `[tour-step="create-case-flyout"] [tour-step="create-case-submit"]`,
anchorPosition: 'leftUp',
hideNextButton: true,
dataTestSubj: getTourAnchor(AlertsCasesTourSteps.submitCase, SecurityStepId.alertsCases),
offset: 20,
ownFocus: false,
initialFocus: `[tour-step="create-case-flyout"] [tour-step="create-case-submit"]`,
},
{
...defaultConfig,
step: AlertsCasesTourSteps.viewCase,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.viewCase.tourTitle', {
defaultMessage: 'View the case',
}),
content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.viewCase.tourContent', {
defaultMessage: 'From the Insights, click through to view the new case',
}),
anchorPosition: 'leftUp',
dataTestSubj: getTourAnchor(AlertsCasesTourSteps.viewCase, SecurityStepId.alertsCases),
},
];
export const sampleCase = {
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.createCase.title', {
defaultMessage: `Demo signal detected`,
}),
description: i18n.translate(
'xpack.securitySolution.guided_onboarding.tour.createCase.description',
{
defaultMessage:
"This is where you'd document a malicious signal. You can include whatever information is relevant to the case and would be helpful for anyone else that needs to read up on it. `Markdown` **formatting** _is_ [supported](https://www.markdownguide.org/cheat-sheet/).",
}
),
};
interface SecurityTourConfig {
[SecurityStepId.rules]: StepConfig[];
[SecurityStepId.alertsCases]: StepConfig[];

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import type { EuiTourStepProps } from '@elastic/eui';
import { GuidedOnboardingTourStep, SecurityTourStep } from './tour_step';
import { SecurityStepId } from './tour_config';
import { AlertsCasesTourSteps, SecurityStepId } from './tour_config';
import { useTourContext } from './tour';
import { mockGlobalState, SUB_PLUGINS_REDUCER, TestProviders } from '../../mock';
import { TimelineId } from '../../../../common/types';
@ -19,8 +20,10 @@ import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/
jest.mock('./tour');
const mockTourStep = jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => (
<span data-test-subj="tourStepMock">{children}</span>
.mockImplementation(({ children, footerAction }: EuiTourStepProps) => (
<span data-test-subj="tourStepMock">
{children} {footerAction}
</span>
));
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
@ -33,16 +36,17 @@ jest.mock('@elastic/eui', () => {
const defaultProps = {
isTourAnchor: true,
step: 1,
stepId: SecurityStepId.alertsCases,
tourId: SecurityStepId.alertsCases,
};
const mockChildren = <h1 data-test-subj="h1">{'random child element'}</h1>;
describe('GuidedOnboardingTourStep', () => {
const incrementStep = jest.fn();
beforeEach(() => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 1,
incrementStep: jest.fn(),
incrementStep,
isTourShown: () => true,
});
jest.clearAllMocks();
@ -69,10 +73,36 @@ describe('GuidedOnboardingTourStep', () => {
expect(tourStep).not.toBeInTheDocument();
expect(header).toBeInTheDocument();
});
it('onClick={undefined}, call incrementStep on click', () => {
const { getByTestId } = render(
<GuidedOnboardingTourStep {...defaultProps}>{mockChildren}</GuidedOnboardingTourStep>,
{ wrapper: TestProviders }
);
const nextButton = getByTestId('onboarding--securityTourNextStepButton');
act(() => {
fireEvent.click(nextButton);
});
expect(incrementStep).toHaveBeenCalled();
});
it('onClick={any function}, do not call incrementStep on click', () => {
const onClick = jest.fn();
const { getByTestId } = render(
<GuidedOnboardingTourStep {...defaultProps} onClick={onClick}>
{mockChildren}
</GuidedOnboardingTourStep>,
{ wrapper: TestProviders }
);
const nextButton = getByTestId('onboarding--securityTourNextStepButton');
act(() => {
fireEvent.click(nextButton);
});
expect(onClick).toHaveBeenCalled();
expect(incrementStep).not.toHaveBeenCalled();
});
});
describe('SecurityTourStep', () => {
const { isTourAnchor: _, ...securityTourStepDefaultProps } = defaultProps;
const { isTourAnchor: _, ...stepDefaultProps } = defaultProps;
beforeEach(() => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 1,
@ -89,7 +119,7 @@ describe('SecurityTourStep', () => {
isTourShown: () => true,
});
render(
<SecurityTourStep {...securityTourStepDefaultProps} step={99}>
<SecurityTourStep {...stepDefaultProps} step={99}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
@ -99,7 +129,7 @@ describe('SecurityTourStep', () => {
it('does not render if tour step does not equal active step', () => {
render(
<SecurityTourStep {...securityTourStepDefaultProps} step={4}>
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.addAlertToCase}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
@ -113,41 +143,40 @@ describe('SecurityTourStep', () => {
incrementStep: jest.fn(),
isTourShown: () => false,
});
render(<SecurityTourStep {...securityTourStepDefaultProps}>{mockChildren}</SecurityTourStep>, {
render(<SecurityTourStep {...stepDefaultProps}>{mockChildren}</SecurityTourStep>, {
wrapper: TestProviders,
});
expect(mockTourStep).not.toHaveBeenCalled();
});
it('renders tour step with correct number of steppers', () => {
render(<SecurityTourStep {...securityTourStepDefaultProps}>{mockChildren}</SecurityTourStep>, {
render(<SecurityTourStep {...stepDefaultProps}>{mockChildren}</SecurityTourStep>, {
wrapper: TestProviders,
});
const mockCall = { ...mockTourStep.mock.calls[0][0] };
expect(mockCall.step).toEqual(1);
expect(mockCall.stepsTotal).toEqual(5);
expect(mockCall.stepsTotal).toEqual(7);
});
it('forces the render for step 5 of the SecurityStepId.alertsCases tour step', () => {
it('forces the render for createCase step of the SecurityStepId.alertsCases tour step', () => {
render(
<SecurityTourStep {...securityTourStepDefaultProps} step={5}>
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.createCase}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
);
const mockCall = { ...mockTourStep.mock.calls[0][0] };
expect(mockCall.step).toEqual(5);
expect(mockCall.stepsTotal).toEqual(5);
});
it('does render next button if step hideNextButton=false ', () => {
it('renders next button', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 3,
incrementStep: jest.fn(),
isTourShown: () => true,
});
render(
<SecurityTourStep {...securityTourStepDefaultProps} step={3}>
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.reviewAlertDetailsFlyout}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
@ -158,6 +187,7 @@ describe('SecurityTourStep', () => {
color="success"
data-test-subj="onboarding--securityTourNextStepButton"
onClick={[Function]}
onKeyDown={[Function]}
size="s"
tour-step="nextButton"
>
@ -177,7 +207,7 @@ describe('SecurityTourStep', () => {
isTourShown: () => true,
});
const { container } = render(
<SecurityTourStep {...securityTourStepDefaultProps} step={3}>
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.reviewAlertDetailsFlyout}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
@ -199,7 +229,7 @@ describe('SecurityTourStep', () => {
isTourShown: () => true,
});
const { container } = render(
<SecurityTourStep {...securityTourStepDefaultProps} step={2}>
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.expandEvent}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
@ -216,7 +246,7 @@ describe('SecurityTourStep', () => {
it('if a tour step does not have children and has anchor, only render tour step', () => {
const { getByTestId } = render(
<SecurityTourStep {...securityTourStepDefaultProps} step={5} />,
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.createCase} />,
{ wrapper: TestProviders }
);
expect(getByTestId('tourStepMock')).toBeInTheDocument();
@ -224,28 +254,12 @@ describe('SecurityTourStep', () => {
it('if a tour step does not have children and does not have anchor, render nothing', () => {
const { queryByTestId } = render(
<SecurityTourStep {...securityTourStepDefaultProps} step={1} />,
<SecurityTourStep {...stepDefaultProps} step={AlertsCasesTourSteps.pointToAlertName} />,
{ wrapper: TestProviders }
);
expect(queryByTestId('tourStepMock')).not.toBeInTheDocument();
});
it('does not render next button if step hideNextButton=true ', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 4,
incrementStep: jest.fn(),
isTourShown: () => true,
});
render(
<SecurityTourStep {...securityTourStepDefaultProps} step={4}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
);
const mockCall = { ...mockTourStep.mock.calls[0][0] };
expect(mockCall.footerAction).toMatchInlineSnapshot(`<React.Fragment />`);
});
it('does not render step if timeline is open', () => {
const mockstate = {
...mockGlobalState,
@ -270,9 +284,25 @@ describe('SecurityTourStep', () => {
render(
<TestProviders store={mockStore}>
<SecurityTourStep {...securityTourStepDefaultProps}>{mockChildren}</SecurityTourStep>
<SecurityTourStep {...stepDefaultProps}>{mockChildren}</SecurityTourStep>
</TestProviders>
);
expect(mockTourStep).not.toHaveBeenCalled();
});
it('does not render next button if step hideNextButton=true ', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 6,
incrementStep: jest.fn(),
isTourShown: () => true,
});
render(
<SecurityTourStep {...stepDefaultProps} step={6}>
{mockChildren}
</SecurityTourStep>,
{ wrapper: TestProviders }
);
const mockCall = { ...mockTourStep.mock.calls[0][0] };
expect(mockCall.footerAction).toMatchInlineSnapshot(`<React.Fragment />`);
});
});

View file

@ -21,25 +21,27 @@ import { AlertsCasesTourSteps, SecurityStepId, securityTourConfig } from './tour
interface SecurityTourStep {
children?: React.ReactElement;
onClick?: () => void;
step: number;
stepId: SecurityStepId;
tourId: SecurityStepId;
}
const isStepExternallyMounted = (stepId: SecurityStepId, step: number) =>
step === AlertsCasesTourSteps.createCase && stepId === SecurityStepId.alertsCases;
const isStepExternallyMounted = (tourId: SecurityStepId, step: number) =>
(step === AlertsCasesTourSteps.createCase || step === AlertsCasesTourSteps.submitCase) &&
tourId === SecurityStepId.alertsCases;
const StyledTourStep = styled(EuiTourStep)<EuiTourStepProps & { stepId: SecurityStepId }>`
const StyledTourStep = styled(EuiTourStep)<EuiTourStepProps & { tourId: SecurityStepId }>`
&.euiPopover__panel[data-popover-open] {
z-index: ${({ step, stepId }) =>
isStepExternallyMounted(stepId, step) ? '9000 !important' : '1000 !important'};
z-index: ${({ step, tourId }) =>
isStepExternallyMounted(tourId, step) ? '9000 !important' : '1000 !important'};
}
`;
export const SecurityTourStep = ({ children, step, stepId }: SecurityTourStep) => {
export const SecurityTourStep = ({ children, onClick, step, tourId }: SecurityTourStep) => {
const { activeStep, incrementStep, isTourShown } = useTourContext();
const tourStep = useMemo(
() => securityTourConfig[stepId].find((config) => config.step === step),
[step, stepId]
() => securityTourConfig[tourId].find((config) => config.step === step),
[step, tourId]
);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
@ -47,27 +49,37 @@ export const SecurityTourStep = ({ children, step, stepId }: SecurityTourStep) =
(state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show
);
const onClick = useCallback(() => incrementStep(stepId), [incrementStep, stepId]);
const onClickNext = useCallback(
// onClick should call incrementStep itself
() => (onClick ? onClick() : incrementStep(tourId)),
[incrementStep, onClick, tourId]
);
// step === AlertsCasesTourSteps.createCase && stepId === SecurityStepId.alertsCases is in Cases app and out of context.
// 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(stepId, step);
const overrideContext = isStepExternallyMounted(tourId, step);
if (
tourStep == null ||
((step !== activeStep || !isTourShown(stepId)) && !overrideContext) ||
((step !== activeStep || !isTourShown(tourId)) && !overrideContext) ||
showTimeline
) {
return children ? children : null;
}
const { anchor, content, imageConfig, dataTestSubj, hideNextButton = false, ...rest } = tourStep;
const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? (
<EuiButton
size="s"
onClick={onClick}
onClick={onClickNext}
onKeyDown={onKeyDown}
color="success"
data-test-subj="onboarding--securityTourNextStepButton"
tour-step="nextButton"
@ -103,25 +115,21 @@ export const SecurityTourStep = ({ children, step, stepId }: SecurityTourStep) =
isStepOpen: true,
// guided onboarding does not allow skipping tour through the steps
onFinish: () => null,
stepsTotal: securityTourConfig[stepId].length,
// TODO: re-add panelProps
// EUI has a bug https://github.com/elastic/eui/issues/6297
// where any panelProps overwrite their panelProps,
// so we lose cool things like the EuiBeacon
// panelProps: {
// 'data-test-subj': dataTestSubj,
// }
stepsTotal: securityTourConfig[tourId].length,
panelProps: {
'data-test-subj': dataTestSubj,
},
};
// tour step either needs children or an anchor element
// see type EuiTourStepAnchorProps
return anchor != null ? (
<>
<StyledTourStep stepId={stepId} {...commonProps} anchor={anchor} />
<StyledTourStep tourId={tourId} {...commonProps} anchor={anchor} />
<>{children}</>
</>
) : children != null ? (
<StyledTourStep stepId={stepId} {...commonProps}>
<StyledTourStep tourId={tourId} {...commonProps}>
{children}
</StyledTourStep>
) : null;

View file

@ -8,8 +8,11 @@
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiToolTip } from '@elastic/eui';
import type { SyntheticEvent, MouseEventHandler, MouseEvent } from 'react';
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useEffect } from 'react';
import { isArray, isNil } from 'lodash/fp';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
import { AlertsCasesTourSteps, SecurityStepId } from '../guided_onboarding_tour/tour_config';
import { useTourContext } from '../guided_onboarding_tour';
import { IP_REPUTATION_LINKS_SETTING, APP_UI_ID } from '../../../../common/constants';
import { encodeIpv6 } from '../../lib/helpers';
import {
@ -260,12 +263,22 @@ const CaseDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
detailName: string;
title?: string;
}> = ({ children, detailName, title }) => {
index?: number;
}> = ({ index, children, detailName, title }) => {
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const { navigateToApp } = useKibana().services.application;
const { activeStep, isTourShown } = useTourContext();
const isTourStepActive = useMemo(
() =>
activeStep === AlertsCasesTourSteps.viewCase &&
isTourShown(SecurityStepId.alertsCases) &&
index === 0,
[activeStep, index, isTourShown]
);
const goToCaseDetails = useCallback(
async (ev) => {
ev.preventDefault();
async (ev?) => {
if (ev) ev.preventDefault();
return navigateToApp(APP_UI_ID, {
deepLinkId: SecurityPageName.case,
path: getCaseDetailsUrl({ id: detailName, search }),
@ -274,15 +287,27 @@ const CaseDetailsLinkComponent: React.FC<{
[detailName, navigateToApp, search]
);
useEffect(() => {
if (isTourStepActive)
document.querySelector(`[tour-step="RelatedCases-accordion"]`)?.scrollIntoView();
}, [isTourStepActive]);
return (
<LinkAnchor
<GuidedOnboardingTourStep
onClick={goToCaseDetails}
href={formatUrl(getCaseDetailsUrl({ id: detailName }))}
data-test-subj="case-details-link"
aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)}
isTourAnchor={isTourStepActive}
step={AlertsCasesTourSteps.viewCase}
tourId={SecurityStepId.alertsCases}
>
{children ? children : detailName}
</LinkAnchor>
<LinkAnchor
onClick={goToCaseDetails}
href={formatUrl(getCaseDetailsUrl({ id: detailName }))}
data-test-subj="case-details-link"
aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)}
>
{children ? children : detailName}
</LinkAnchor>
</GuidedOnboardingTourStep>
);
};
export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent);

View file

@ -0,0 +1,152 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { useAddToCaseActions } from './use_add_to_case_actions';
import { TestProviders } from '../../../../common/mock';
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
sampleCase,
} from '../../../../common/components/guided_onboarding_tour/tour_config';
import { CasesTourSteps } from '../../../../common/components/guided_onboarding_tour/cases_tour_steps';
jest.mock('../../../../common/components/guided_onboarding_tour');
jest.mock('../../../../common/lib/kibana');
const mockTourStep = jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => (
<span data-test-subj="contextMenuMock">{children}</span>
));
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
EuiContextMenuItem: (props: any) => mockTourStep(props),
};
});
const defaultProps = {
onMenuItemClick: () => null,
isActiveTimelines: false,
isInDetections: true,
ecsData: {
_id: '123',
event: {
kind: ['signal'],
},
},
};
describe('useAddToCaseActions', () => {
const open = jest.fn();
const submit = jest.fn();
const addToNewCase = jest.fn().mockReturnValue({
open,
submit,
});
beforeEach(() => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: 1,
incrementStep: () => null,
isTourShown: () => false,
});
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
all: true,
create: true,
read: true,
update: true,
delete: true,
push: true,
});
(useKibana as jest.Mock).mockReturnValue({
services: {
cases: {
hooks: {
getUseCasesAddToNewCaseFlyout: addToNewCase,
getUseCasesAddToExistingCaseModal: () => null,
},
helpers: {
getRuleIdFromEvent: () => null,
},
},
},
});
jest.clearAllMocks();
});
it('should render case options when event is alert ', () => {
const { result } = renderHook(() => useAddToCaseActions(defaultProps), {
wrapper: TestProviders,
});
expect(result.current.addToCaseActionItems.length).toEqual(2);
expect(result.current.addToCaseActionItems[0].props['data-test-subj']).toEqual(
'add-to-existing-case-action'
);
expect(result.current.addToCaseActionItems[1].props['data-test-subj']).toEqual(
'add-to-new-case-action'
);
});
it('should not render case options when event is not alert ', () => {
const { result } = renderHook(
() => useAddToCaseActions({ ...defaultProps, ecsData: { _id: '123' } }),
{
wrapper: TestProviders,
}
);
expect(result.current.addToCaseActionItems.length).toEqual(0);
});
it('should call getUseCasesAddToNewCaseFlyout with attachments only when step is not active', () => {
const { result } = renderHook(() => useAddToCaseActions(defaultProps), {
wrapper: TestProviders,
});
act(() => {
result.current.handleAddToNewCaseClick();
});
expect(open).toHaveBeenCalledWith({
attachments: [{ alertId: '123', index: '', rule: null, type: 'alert' }],
});
});
it('should call getUseCasesAddToNewCaseFlyout with tour step with step is active and increment step', () => {
const incrementStep = jest.fn();
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.addAlertToCase,
incrementStep,
isTourShown: () => true,
});
const { result } = renderHook(() => useAddToCaseActions(defaultProps), {
wrapper: TestProviders,
});
act(() => {
result.current.handleAddToNewCaseClick();
});
expect(open).toHaveBeenCalledWith({
attachments: [{ alertId: '123', index: '', rule: null, type: 'alert' }],
headerContent: <CasesTourSteps />,
});
expect(incrementStep).toHaveBeenCalled();
});
it('should prefill getUseCasesAddToNewCaseFlyout with tour step when step is active', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.addAlertToCase,
incrementStep: () => null,
isTourShown: () => true,
});
renderHook(() => useAddToCaseActions(defaultProps), {
wrapper: TestProviders,
});
expect(addToNewCase.mock.calls[0][0].initialValue).toEqual(sampleCase);
});
it('should not prefill getUseCasesAddToNewCaseFlyout with tour step when step is not active', () => {
renderHook(() => useAddToCaseActions(defaultProps), {
wrapper: TestProviders,
});
expect(addToNewCase.mock.calls[0][0]).not.toHaveProperty('initialValue');
});
});

View file

@ -9,9 +9,10 @@ import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { CommentType } from '@kbn/cases-plugin/common';
import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
import { GuidedOnboardingTourStep } from '../../../../common/components/guided_onboarding_tour/tour_step';
import { CasesTourSteps } from '../../../../common/components/guided_onboarding_tour/cases_tour_steps';
import {
AlertsCasesTourSteps,
sampleCase,
SecurityStepId,
} from '../../../../common/components/guided_onboarding_tour/tour_config';
import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
@ -59,18 +60,30 @@ export const useAddToCaseActions = ({
: [];
}, [casesUi.helpers, ecsData, nonEcsData]);
const { activeStep, endTourStep, incrementStep, isTourShown } = useTourContext();
const { activeStep, incrementStep, setStep, isTourShown } = useTourContext();
const afterCaseCreated = useCallback(async () => {
if (isTourShown(SecurityStepId.alertsCases)) {
endTourStep(SecurityStepId.alertsCases);
setStep(SecurityStepId.alertsCases, AlertsCasesTourSteps.viewCase);
}
}, [endTourStep, isTourShown]);
}, [setStep, isTourShown]);
const prefillCasesValue = useMemo(
() =>
isTourShown(SecurityStepId.alertsCases) &&
(activeStep === AlertsCasesTourSteps.addAlertToCase ||
activeStep === AlertsCasesTourSteps.createCase ||
activeStep === AlertsCasesTourSteps.submitCase)
? { initialValue: sampleCase }
: {},
[activeStep, isTourShown]
);
const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({
onClose: onMenuItemClick,
onSuccess,
afterCaseCreated,
...prefillCasesValue,
});
const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({
@ -83,17 +96,11 @@ export const useAddToCaseActions = ({
onMenuItemClick();
createCaseFlyout.open({
attachments: caseAttachments,
// activeStep will be 4 on first render because not yet incremented
// if the user closes the flyout without completing the form and comes back, we will be at step 5
...(isTourShown(SecurityStepId.alertsCases) &&
(activeStep === AlertsCasesTourSteps.addAlertToCase ||
activeStep === AlertsCasesTourSteps.createCase)
// activeStep will be AlertsCasesTourSteps.addAlertToCase on first render because not yet incremented
// if the user closes the flyout without completing the form and comes back, we will be at step AlertsCasesTourSteps.createCase
...(isTourShown(SecurityStepId.alertsCases)
? {
headerContent: (
// isTourAnchor=true no matter what in order to
// force active guide step outside of security solution (cases)
<GuidedOnboardingTourStep isTourAnchor step={5} stepId={SecurityStepId.alertsCases} />
),
headerContent: <CasesTourSteps />,
}
: {}),
});
@ -123,6 +130,7 @@ export const useAddToCaseActions = ({
<EuiContextMenuItem
aria-label={ariaLabel}
data-test-subj="add-to-existing-case-action"
key="add-to-existing-case-action"
onClick={handleAddToExistingCaseClick}
size="s"
>
@ -132,6 +140,7 @@ export const useAddToCaseActions = ({
<EuiContextMenuItem
aria-label={ariaLabel}
data-test-subj="add-to-new-case-action"
key="add-to-new-case-action"
onClick={handleAddToNewCaseClick}
size="s"
>
@ -153,5 +162,6 @@ export const useAddToCaseActions = ({
return {
addToCaseActionItems,
handleAddToNewCaseClick,
};
};

View file

@ -45,6 +45,7 @@ export const useResponderActionItem = (
if (isResponseActionsConsoleEnabled && !isAuthzLoading && canAccessResponseConsole && isAlert) {
actions.push(
<ResponderContextMenuItem
key="endpointResponseActions-action-item"
endpointId={isEndpointAlert ? endpointId : ''}
onClick={onClick}
/>

View file

@ -9,7 +9,10 @@ import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
import { SecurityStepId } from '../../../common/components/guided_onboarding_tour/tour_config';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../../common/components/guided_onboarding_tour/tour_config';
import { isActiveTimeline } from '../../../helpers';
import { TableId } from '../../../../common/types';
import { useResponderActionItem } from '../endpoint_responder';
@ -224,7 +227,7 @@ export const TakeActionDropdown = React.memo(
scopeId as TableId
);
const { addToCaseActionItems } = useAddToCaseActions({
const { addToCaseActionItems, handleAddToNewCaseClick } = useAddToCaseActions({
ecsData,
nonEcsData: detailsData?.map((d) => ({ field: d.field, value: d.values })) ?? [],
onMenuItemClick,
@ -256,7 +259,11 @@ export const TakeActionDropdown = React.memo(
const takeActionButton = useMemo(
() => (
<GuidedOnboardingTourStep step={4} stepId={SecurityStepId.alertsCases}>
<GuidedOnboardingTourStep
onClick={handleAddToNewCaseClick}
step={AlertsCasesTourSteps.addAlertToCase}
tourId={SecurityStepId.alertsCases}
>
<EuiButton
data-test-subj="take-action-dropdown-btn"
fill
@ -269,7 +276,7 @@ export const TakeActionDropdown = React.memo(
</GuidedOnboardingTourStep>
),
[togglePopoverHandler]
[handleAddToNewCaseClick, togglePopoverHandler]
);
return items.length && !loadingEventDetails && ecsData ? (

View file

@ -9,7 +9,10 @@ import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import React, { useMemo } from 'react';
import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../../../common/components/top_n/helpers';
import { SecurityStepId } from '../../../common/components/guided_onboarding_tour/tour_config';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../../common/components/guided_onboarding_tour/tour_config';
import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants';
import { TimelineId } from '../../../../common/types';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
@ -31,15 +34,16 @@ export const RenderCellValue: React.FC<EuiDataGridCellValueElementProps & CellVa
() =>
columnId === SIGNAL_RULE_NAME_FIELD_NAME &&
isDetectionsAlertsTable(scopeId) &&
rowIndex === 0,
[columnId, rowIndex, scopeId]
rowIndex === 0 &&
!props.isDetails,
[columnId, props.isDetails, rowIndex, scopeId]
);
return (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={1}
stepId={SecurityStepId.alertsCases}
step={AlertsCasesTourSteps.pointToAlertName}
tourId={SecurityStepId.alertsCases}
>
<DefaultCellRenderer {...props} />
</GuidedOnboardingTourStep>

View file

@ -168,14 +168,22 @@ export const isDetectionsPath = (pathname: string): boolean => {
});
};
export const isAlertDetailsPage = (pathname: string): boolean => {
const isAlertsPath = (pathname: string): boolean => {
return !!matchPath(pathname, {
path: `${ALERTS_PATH}/:detailName/:tabName`,
path: `${ALERTS_PATH}`,
strict: false,
exact: true,
});
};
const isCaseDetailsPath = (pathname: string): boolean => {
return !!matchPath(pathname, {
path: `${CASES_PATH}/:detailName`,
strict: false,
});
};
export const isTourPath = (pathname: string): boolean =>
isAlertsPath(pathname) || isCaseDetailsPath(pathname);
export const isThreatIntelligencePath = (pathname: string): boolean => {
return !!matchPath(pathname, {
path: `(${THREAT_INTELLIGENCE_PATH})`,

View file

@ -14,7 +14,10 @@ import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../../../../../common/components/top_n/helpers';
import { useTourContext } from '../../../../../common/components/guided_onboarding_tour';
import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../../../../common/components/guided_onboarding_tour/tour_config';
import { getScopedActions, isTimelineScope } from '../../../../../helpers';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { eventHasNotes, getEventType, getPinOnClick } from '../helpers';
@ -215,8 +218,11 @@ const ActionsComponent: React.FC<ActionProps> = ({
);
const onExpandEvent = useCallback(() => {
const isStep2Active = activeStep === 2 && isTourShown(SecurityStepId.alertsCases);
if (isTourAnchor && isStep2Active) {
if (
isTourAnchor &&
activeStep === AlertsCasesTourSteps.expandEvent &&
isTourShown(SecurityStepId.alertsCases)
) {
incrementStep(SecurityStepId.alertsCases);
}
onEventDetailsPanelOpened();
@ -243,8 +249,9 @@ const ActionsComponent: React.FC<ActionProps> = ({
)}
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={2}
stepId={SecurityStepId.alertsCases}
onClick={onExpandEvent}
step={AlertsCasesTourSteps.expandEvent}
tourId={SecurityStepId.alertsCases}
>
<div key="expand-event">
<EventsTdContent textAlign="center" width={DEFAULT_ACTION_BUTTON_WIDTH}>