mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security solution] Guided onboarding, alerts & cases design updates (#144249)
This commit is contained in:
parent
47f38bc3df
commit
b721fdcf42
28 changed files with 858 additions and 219 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -16,6 +16,7 @@ const SubmitCaseButtonComponent: React.FC = () => {
|
|||
|
||||
return (
|
||||
<EuiButton
|
||||
tour-step="create-case-submit"
|
||||
data-test-subj="create-case-submit"
|
||||
fill
|
||||
iconType="plusInCircle"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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] ? ',' : ''}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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 />`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -45,6 +45,7 @@ export const useResponderActionItem = (
|
|||
if (isResponseActionsConsoleEnabled && !isAuthzLoading && canAccessResponseConsole && isAlert) {
|
||||
actions.push(
|
||||
<ResponderContextMenuItem
|
||||
key="endpointResponseActions-action-item"
|
||||
endpointId={isEndpointAlert ? endpointId : ''}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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})`,
|
||||
|
|
|
@ -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}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue