From b721fdcf4288e5a26b39672a178f12c582624585 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 9 Nov 2022 14:13:10 -0700 Subject: [PATCH] [Security solution] Guided onboarding, alerts & cases design updates (#144249) --- .github/CODEOWNERS | 1 + .../create/flyout/create_case_flyout.tsx | 6 +- .../public/components/create/form.test.tsx | 32 +++- .../cases/public/components/create/form.tsx | 6 +- .../public/components/create/form_context.tsx | 5 +- .../components/create/submit_button.tsx | 1 + .../public/cases/pages/index.test.tsx | 93 +++++++++++ .../public/cases/pages/index.tsx | 17 +- .../event_details/event_details.tsx | 10 +- .../insights/insight_accordion.tsx | 6 +- .../insights/related_cases.test.tsx | 115 ++++++++----- .../event_details/insights/related_cases.tsx | 61 ++++--- .../guided_onboarding_tour/README.md | 20 ++- .../cases_tour_steps.test.tsx | 34 ++++ .../cases_tour_steps.tsx | 48 ++++++ .../guided_onboarding_tour/tour.test.tsx | 44 +++-- .../guided_onboarding_tour/tour.tsx | 40 ++--- .../guided_onboarding_tour/tour_config.ts | 72 ++++++++- .../guided_onboarding_tour/tour_step.test.tsx | 106 +++++++----- .../guided_onboarding_tour/tour_step.tsx | 60 ++++--- .../public/common/components/links/index.tsx | 45 ++++-- .../use_add_to_case_actions.test.tsx | 152 ++++++++++++++++++ .../use_add_to_case_actions.tsx | 38 +++-- .../use_responder_action_item.tsx | 1 + .../components/take_action_dropdown/index.tsx | 15 +- .../render_cell_value.tsx | 14 +- .../security_solution/public/helpers.tsx | 14 +- .../timeline/body/actions/index.tsx | 17 +- 28 files changed, 856 insertions(+), 217 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/pages/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ca96fa8a0905b0..30827ee5182795 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx index 8f5e420f6b79d0..d20d14c5746988 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx @@ -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; attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode; + initialValue?: Pick; } const StyledFlyout = styled(EuiFlyout)` @@ -72,7 +74,7 @@ const FormWrapper = styled.div` `; export const CreateCaseFlyout = React.memo( - ({ 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( ( onCancel={handleCancel} onSuccess={handleOnSuccess} withSteps={false} + initialValue={initialValue} /> diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index ddc65f443bdb30..0f05f7a25ad212 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + + expect(titleInput).toHaveValue('title'); + expect(descriptionInput).toHaveValue('description'); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index e1a0c4f3b1cea1..4ec587667e18d9 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -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 Promise; timelineIntegration?: CasesTimelineIntegration; attachments?: CaseAttachmentsWithoutOwner; + initialValue?: Pick; } const empty: ActionConnector[] = []; @@ -79,6 +80,7 @@ export const CreateCaseFormFields: React.FC = 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 = React.memo( onSuccess, timelineIntegration, attachments, + initialValue, }) => ( Promise; attachments?: CaseAttachmentsWithoutOwner; + initialValue?: Pick; } export const FormContext: React.FC = ({ @@ -51,6 +53,7 @@ export const FormContext: React.FC = ({ children, onSuccess, attachments, + initialValue, }) => { const { data: connectors = [], isLoading: isLoadingConnectors } = useGetConnectors(); const { owner, appId } = useCasesContext(); @@ -128,7 +131,7 @@ export const FormContext: React.FC = ({ ); const { form } = useForm({ - defaultValue: initialCaseValue, + defaultValue: { ...initialCaseValue, ...initialValue }, options: { stripEmptyFields: false }, schema, onSubmit: submitCase, diff --git a/x-pack/plugins/cases/public/components/create/submit_button.tsx b/x-pack/plugins/cases/public/components/create/submit_button.tsx index 2c3ecd563df731..b3eb4724fd1c9d 100644 --- a/x-pack/plugins/cases/public/components/create/submit_button.tsx +++ b/x-pack/plugins/cases/public/components/create/submit_button.tsx @@ -16,6 +16,7 @@ const SubmitCaseButtonComponent: React.FC = () => { return ( { + 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( + + + , + { 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( + + + , + { 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( + + + , + { wrapper: TestProviders } + ); + expect(endTourStep).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 5db5185679ab33..26f600bf7e0cb1 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -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(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 ( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 173001dc42aff5..622d3035a7de67 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -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 = ({ return ( 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( - ({ 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( // The accordion can display the content now return ( {renderContent()} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx index d9b83f8e66388c..8e6bc304e1a381 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx @@ -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( - - - - ); - + }); + test('should not show related cases when user does not have permissions', async () => { + await act(async () => { + render( + + + + ); + }); 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( ); - 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( ); - - 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( ); - 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', () => { ); + }); + 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'); + }); - 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'); - }); + test('Should not open the related cases accordion when isTourActive=false', async () => { + mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); + await act(async () => { + render( + + + + ); + }); + 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( + + + + ); }); + expect(scrollToMock).toHaveBeenCalled(); + expect( + screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen') + ).toBe(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx index 19742a9b0a915d..8444e10d11cfd1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx @@ -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(({ eventId }) => { const [relatedCases, setRelatedCases] = useState(undefined); const [hasError, setHasError] = useState(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)); + const [shouldFetch, setShouldFetch] = useState(false); + + useEffect(() => { + if (!shouldFetch) { + return; } - setRelatedCases(relatedCaseList); - }, [eventId, cases.api, toasts]); + 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(() => { - getRelatedCases(); - }, [eventId, getRelatedCases]); + setShouldFetch(true); + }, [eventId]); let state: InsightAccordionState = 'loading'; if (hasError) { @@ -68,6 +92,7 @@ export const RelatedCases = React.memo(({ eventId }) => { state={state} text={getTextFromState(state, relatedCases?.length)} renderContent={renderContent} + forceState={isTourActive ? 'open' : undefined} /> ); }); @@ -95,7 +120,7 @@ function renderCaseContent(relatedCases: RelatedCaseList = []) { id && title ? ( {' '} - + {title} {relatedCases[index + 1] ? ',' : ''} diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md index 483d9c30cb82ca..a369669613bb42 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md @@ -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 1 - 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 ( @@ -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. 5 @@ -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) - + ), } : {}), @@ -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. diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx new file mode 100644 index 00000000000000..3cf8d696833b3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx @@ -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 }) => ( +