diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx index df2ba80901738..effa3d450af89 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.test.tsx @@ -44,4 +44,24 @@ describe('CreateCaseFlyout', () => { }); expect(onClose).toBeCalled(); }); + + it('renders headerContent when passed', async () => { + const headerContent =

; + const { getByTestId } = mockedContext.render( + + ); + + await act(async () => { + expect(getByTestId('testing123')).toBeTruthy(); + expect(getByTestId('create-case-flyout-header').children.length).toEqual(2); + }); + }); + + it('does not render headerContent when undefined', async () => { + const { getByTestId } = mockedContext.render(); + + await act(async () => { + expect(getByTestId('create-case-flyout-header').children.length).toEqual(1); + }); + }); }); 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 75a18f2e70209..8f5e420f6b79d 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 @@ -25,6 +25,7 @@ export interface CreateCaseFlyoutProps { onClose?: () => void; onSuccess?: (theCase: Case) => Promise; attachments?: CaseAttachmentsWithoutOwner; + headerContent?: React.ReactNode; } const StyledFlyout = styled(EuiFlyout)` @@ -71,9 +72,10 @@ const FormWrapper = styled.div` `; export const CreateCaseFlyout = React.memo( - ({ afterCaseCreated, onClose, onSuccess, attachments }) => { + ({ afterCaseCreated, onClose, onSuccess, attachments, headerContent }) => { const handleCancel = onClose || function () {}; const handleOnSuccess = onSuccess || async function () {}; + return ( @@ -83,10 +85,11 @@ export const CreateCaseFlyout = React.memo( // maskProps is needed in order to apply the z-index to the parent overlay element, not to the flyout only maskProps={{ className: maskOverlayClassName }} > - +

{i18n.CREATE_CASE_TITLE}

+ {headerContent && headerContent} diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 86b03f46bf745..a92046e8c9928 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type React from 'react'; import { useCallback } from 'react'; import type { CaseAttachmentsWithoutOwner } from '../../../types'; import { useCasesToast } from '../../../common/use_cases_toast'; @@ -29,12 +30,16 @@ export const useCasesAddToNewCaseFlyout = (props: AddToNewCaseFlyoutProps = {}) }, [dispatch]); const openFlyout = useCallback( - ({ attachments }: { attachments?: CaseAttachmentsWithoutOwner } = {}) => { + ({ + attachments, + headerContent, + }: { attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode } = {}) => { dispatch({ type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT, payload: { ...props, attachments, + headerContent, onClose: () => { closeFlyout(); if (props.onClose) { diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index aa581971e5f09..a92dde76777f5 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -64,6 +64,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables endpoint package level rbac */ endpointRbacEnabled: false, + + /** + * Enables the Guided Onboarding tour in security + */ + guidedOnboarding: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts index 41e77e8aeac29..0339445bc8240 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/guided_onboarding/tour.cy.ts @@ -7,7 +7,6 @@ import { login, visit } from '../../tasks/login'; import { completeTour, goToNextStep, skipTour } from '../../tasks/guided_onboarding'; -import { SECURITY_TOUR_ACTIVE_KEY } from '../../../public/common/components/guided_onboarding'; import { OVERVIEW_URL } from '../../urls/navigation'; import { WELCOME_STEP, @@ -21,11 +20,11 @@ before(() => { login(); }); -describe('Guided onboarding tour', () => { +// need to redo these tests for new implementation +describe.skip('Guided onboarding tour', () => { describe('Tour is enabled', () => { beforeEach(() => { visit(OVERVIEW_URL); - window.localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, 'true'); }); it('can be completed', () => { diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 4a6e3a105ee72..bddfc36c7d61d 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -19,6 +19,7 @@ "embeddable", "eventLog", "features", + "guidedOnboarding", "inspector", "kubernetesSecurity", "lens", diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index 37efdce430317..c5d86011226c3 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -10,7 +10,7 @@ import { EuiHeaderSection, EuiHeaderSectionItem, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { i18n } from '@kbn/i18n'; @@ -28,7 +28,6 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer'; -import { useTourContext } from '../../../common/components/guided_onboarding'; const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', { defaultMessage: 'Add integrations', @@ -83,12 +82,6 @@ export const GlobalHeader = React.memo( }; }, [portalNode, setHeaderActionMenu, theme.theme$]); - const { isTourShown, endTour } = useTourContext(); - const closeOnboardingTourIfShown = useCallback(() => { - if (isTourShown) { - endTour(); - } - }, [isTourShown, endTour]); return ( @@ -105,7 +98,6 @@ export const GlobalHeader = React.memo( data-test-subj="add-data" href={href} iconType="indexOpen" - onClick={closeOnboardingTourIfShown} > {BUTTON_ADD_DATA} diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 36940cc055645..3711a990ef726 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -22,7 +22,7 @@ import { useUpgradeSecurityPackages } from '../../common/hooks/use_upgrade_secur import { GlobalHeader } from './global_header'; import { ConsoleManager } from '../../management/components/console/components/console_manager'; -import { TourContextProvider } from '../../common/components/guided_onboarding'; +import { TourContextProvider } from '../../common/components/guided_onboarding_tour'; import { useUrlState } from '../../common/hooks/use_url_state'; import { useUpdateBrowserTitle } from '../../common/hooks/use_update_browser_title'; 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 a0ef3b8904e3f..edfe3e70e6ca0 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 @@ -7,19 +7,22 @@ import type { EuiTabbedContentTab } from '@elastic/eui'; import { - EuiHorizontalRule, - EuiTabbedContent, - EuiSpacer, - EuiLoadingContent, - EuiNotificationBadge, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, + EuiLoadingContent, EuiLoadingSpinner, + EuiNotificationBadge, + EuiSpacer, + EuiTabbedContent, } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash'; +import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; +import { isDetectionsAlertsTable } from '../top_n/helpers'; +import { 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'; @@ -179,6 +182,8 @@ const EventDetailsComponent: React.FC = ({ [detailsEcsData] ); + const isTourAnchor = useMemo(() => isDetectionsAlertsTable(scopeId), [scopeId]); + const showThreatSummary = useMemo(() => { const hasEnrichments = enrichmentCount > 0; const hasRiskInfoWithLicense = isLicenseValid && (hostRisk || userRisk); @@ -401,14 +406,26 @@ const EventDetailsComponent: React.FC = ({ [tabs, selectedTabId] ); + const tourAnchor = useMemo( + () => (isTourAnchor ? { 'tour-step': getTourAnchor(3, SecurityStepId.alertsCases) } : {}), + [isTourAnchor] + ); + return ( - + + + ); }; EventDetailsComponent.displayName = 'EventDetailsComponent'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx deleted file mode 100644 index e2cf3be0ae07d..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act, renderHook } from '@testing-library/react-hooks'; - -import { - SECURITY_TOUR_ACTIVE_KEY, - SECURITY_TOUR_STEP_KEY, - TourContextProvider, - useTourContext, -} from './tour'; - -describe('useTourContext', () => { - describe('localStorage', () => { - let localStorageTourActive: string | null; - let localStorageTourStep: string | null; - - beforeAll(() => { - localStorageTourActive = localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY); - localStorage.removeItem(SECURITY_TOUR_ACTIVE_KEY); - localStorageTourStep = localStorage.getItem(SECURITY_TOUR_STEP_KEY); - localStorage.removeItem(SECURITY_TOUR_STEP_KEY); - }); - - afterAll(() => { - if (localStorageTourActive) { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, localStorageTourActive); - } - if (localStorageTourStep) { - localStorage.setItem(SECURITY_TOUR_STEP_KEY, localStorageTourStep); - } - }); - - test('tour is disabled', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(false)); - const { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(false); - }); - - test('tour is enabled', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(true)); - const { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(true); - }); - test('endTour callback', () => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(true)); - let { result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - }); - expect(result.current.isTourShown).toBe(true); - act(() => { - result.current.endTour(); - }); - const localStorageValue = JSON.parse(localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY)!); - expect(localStorageValue).toBe(false); - - ({ result } = renderHook(() => useTourContext(), { - wrapper: TourContextProvider, - })); - expect(result.current.isTourShown).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx deleted file mode 100644 index 27288bb8a7145..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 type { ReactChild } from 'react'; -import React, { createContext, useContext, useState, useCallback } from 'react'; - -import type { EuiTourStepProps } from '@elastic/eui'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiTourStep, - EuiText, - EuiSpacer, - EuiImage, - useIsWithinBreakpoints, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import type { StepConfig } from './tour_config'; -import { tourConfig } from './tour_config'; - -export const SECURITY_TOUR_ACTIVE_KEY = 'guidedOnboarding.security.tourActive'; -export const SECURITY_TOUR_STEP_KEY = 'guidedOnboarding.security.tourStep'; -const getIsTourActiveFromLocalStorage = (): boolean => { - const localStorageValue = localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY); - return localStorageValue ? JSON.parse(localStorageValue) : false; -}; -export const saveIsTourActiveToLocalStorage = (isTourActive: boolean): void => { - localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(isTourActive)); -}; - -export const getTourStepFromLocalStorage = (): number => { - return Number(localStorage.getItem(SECURITY_TOUR_STEP_KEY) ?? 1); -}; -const saveTourStepToLocalStorage = (step: number): void => { - localStorage.setItem(SECURITY_TOUR_STEP_KEY, JSON.stringify(step)); -}; - -const minWidth: EuiTourStepProps['minWidth'] = 360; -const maxWidth: EuiTourStepProps['maxWidth'] = 360; -const offset: EuiTourStepProps['offset'] = 20; -const repositionOnScroll: EuiTourStepProps['repositionOnScroll'] = true; - -const getSteps = (tourControls: { - activeStep: number; - incrementStep: () => void; - resetTour: () => void; -}) => { - const { activeStep, incrementStep, resetTour } = tourControls; - const footerAction = ( - - - resetTour()} - data-test-subj="onboarding--securityTourSkipButton" - > - - - - - incrementStep()} - color="success" - data-test-subj="onboarding--securityTourNextStepButton" - > - - - - - ); - const lastStepFooter = ( - resetTour()} - data-test-subj="onboarding--securityTourEndButton" - > - - - ); - return tourConfig.map((stepConfig: StepConfig) => { - const { content, imageConfig, dataTestSubj, ...rest } = stepConfig; - return ( - resetTour()} - panelProps={{ - 'data-test-subj': dataTestSubj, - }} - content={ - <> - -

{content}

-
- {imageConfig && ( - <> - - - - )} - - } - footerAction={activeStep === tourConfig.length ? lastStepFooter : footerAction} - /> - ); - }); -}; - -export interface TourContextValue { - isTourShown: boolean; - endTour: () => void; -} - -const TourContext = createContext({ - isTourShown: false, - endTour: () => {}, -} as TourContextValue); - -export const TourContextProvider = ({ children }: { children: ReactChild }) => { - const [isTourActive, _setIsTourActive] = useState(getIsTourActiveFromLocalStorage()); - const setIsTourActive = useCallback((value: boolean) => { - _setIsTourActive(value); - saveIsTourActiveToLocalStorage(value); - }, []); - - const [activeStep, _setActiveStep] = useState(getTourStepFromLocalStorage()); - - const incrementStep = useCallback(() => { - _setActiveStep((prevState) => { - const nextStep = (prevState >= tourConfig.length ? 0 : prevState) + 1; - saveTourStepToLocalStorage(nextStep); - return nextStep; - }); - }, []); - - const resetStep = useCallback(() => { - _setActiveStep(1); - saveTourStepToLocalStorage(1); - }, []); - - const resetTour = useCallback(() => { - setIsTourActive(false); - resetStep(); - }, [setIsTourActive, resetStep]); - - const isSmallScreen = useIsWithinBreakpoints(['xs', 's']); - const showTour = isTourActive && !isSmallScreen; - const context: TourContextValue = { isTourShown: showTour, endTour: resetTour }; - return ( - - <> - {children} - {showTour && <>{getSteps({ activeStep, incrementStep, resetTour })}} - - - ); -}; - -export const useTourContext = (): TourContextValue => { - const ctx = useContext(TourContext); - if (!ctx) { - throw new Error('useTourContext can only be called inside of TourContext!'); - } - return ctx; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts deleted file mode 100644 index 5a0f6f30daadc..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/tour_config.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * 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 type { EuiTourStepProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import alertsGif from '../../images/onboarding_tour_step_alerts.gif'; -import casesGif from '../../images/onboarding_tour_step_cases.gif'; - -export type StepConfig = Pick & { - anchor: string; - dataTestSubj: string; - imageConfig?: { - altText: string; - src: string; - }; -}; - -type TourConfig = StepConfig[]; - -export const tourConfig: TourConfig = [ - { - step: 1, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle', { - defaultMessage: 'Welcome to Elastic Security', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent', - { - defaultMessage: - 'Take a quick tour to explore a unified workflow for investigating suspicious activity.', - } - ), - anchor: `[id^="SolutionNav"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'welcomeStep', - }, - { - step: 2, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle', { - defaultMessage: 'Protect your ecosystem', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent', - { - defaultMessage: - 'Decide what matters to you and your environment and create rules to detect and prevent malicious activity. ', - } - ), - anchor: `[data-test-subj="groupedNavItemLink-administration"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'manageStep', - }, - { - step: 3, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle', { - defaultMessage: 'Get notified when something changes', - }), - content: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent', - { - defaultMessage: - "Know when a rule's conditions are met, so you can start your investigation right away. Set up notifications with third-party platforms like Slack, PagerDuty, and ServiceNow.", - } - ), - anchor: `[data-test-subj="groupedNavItemLink-alerts"]`, - anchorPosition: 'rightUp', - imageConfig: { - src: alertsGif, - altText: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText', - { - defaultMessage: 'Alerts demonstration', - } - ), - }, - dataTestSubj: 'alertsStep', - }, - { - step: 4, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle', { - defaultMessage: 'Create a case to track your investigation', - }), - content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent', { - defaultMessage: - 'Collect evidence, add more collaborators, and even push case details to third-party case management systems.', - }), - anchor: `[data-test-subj="groupedNavItemLink-cases"]`, - anchorPosition: 'rightUp', - imageConfig: { - src: casesGif, - altText: i18n.translate( - 'xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText', - { - defaultMessage: 'Cases demonstration', - } - ), - }, - dataTestSubj: 'casesStep', - }, - { - step: 5, - title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle', { - defaultMessage: `Start gathering your data!`, - }), - content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent', { - defaultMessage: `Collect data from your endpoints using the Elastic Agent and a variety of third-party integrations.`, - }), - anchor: `[data-test-subj="add-data"]`, - anchorPosition: 'rightUp', - dataTestSubj: 'dataStep', - }, -]; 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 new file mode 100644 index 0000000000000..eb30e20f1318e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md @@ -0,0 +1,140 @@ +## 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: +``` +type EuiTourStepAnchorProps = ExclusiveUnion<{ + //Element to which the tour step popover attaches when open + children: ReactElement; + // Selector or reference to the element to which the tour step popover attaches when open + anchor?: never; +}, { + children?: never; + anchor: ElementTarget; +}>; +``` + +It was important that the `EuiTourStep` **anchor** is in the DOM when the tour step becomes active. Additionally, when the **anchor** leaves the DOM, we need `EuiTourStep` to leave the DOM as well. + +## How to use components (for OLM/D&R step engineers) + +- Define your steps in [`./tour_config.ts`](https://github.com/elastic/kibana/pull/143598/files#diff-2c0372fc996eadbff00dddb92101432bf38cc1613895cb9a208abd8eb2e12930R136) in the `securityTourConfig` const +- For each step, implement the `GuidedOnboardingTourStep` component at the location of the **anchor**. As stated in the previous section, there are two ways to define the **anchor**. I will explain examples of both methods: + +1. **Method 1 - as children.** Looking at step 1 of the `SecurityStepId.alertsCases` tour. In the `alertsCasesConfig` you can see the config for this step looks like: + + ``` + { + ...defaultConfig, + step: 1, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourTitle', { + defaultMessage: 'Test alert for practice', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourContent', + { + defaultMessage: + 'To help you practice triaging alerts, we enabled a rule to create your first alert.', + } + ), + anchorPosition: 'downCenter', + dataTestSubj: getTourAnchor(1, SecurityStepId.alertsCases), + } + ``` + + Notice that **no anchor prop is defined** in the step 1 config. + As you can see pictured below, the tour step anchor is the Rule name of the first alert. + + 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: + + ``` + export const RenderCellValue = (props) => { + const { columnId, rowIndex, scopeId } = props; + const isTourAnchor = useMemo( + () => + columnId === SIGNAL_RULE_NAME_FIELD_NAME && + isDetectionsAlertsTable(scopeId) && + rowIndex === 0, + [columnId, rowIndex, scopeId] + ); + + return ( + + + + ); + }; + ``` + +2. **Method 2 - as anchor props.** Looking at step 5 of the `SecurityStepId.alertsCases` tour. In the `alertsCasesConfig` you can see the config for this step looks like: + + ``` + { + ...defaultConfig, + step: 5, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.createCase.tourTitle', { + defaultMessage: `Add details`, + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.createCase.tourContent', + { + defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`, + } + ), + anchor: `[data-test-subj="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. + As you can see pictured below, the tour step anchor is the create case flyout and the next button is hidden. + + 5 + + + Since cases is its own plugin and we are using a method to generate the flyout, we cannot wrap the flyout as children of the `GuidedOnboardingTourStep`. We do however need the `EuiTourStep` component to mount in the same location as the anchor. Therefore, I had to pass a new optional property to the case component called `headerContent` that simply accepts and renders ` React.ReactNode` at the top of the flyout. In the code, this looks something like: + + ``` + createCaseFlyout.open({ + attachments: caseAttachments, + ...(isTourShown(SecurityStepId.alertsCases) && activeStep === 4 + ? { + headerContent: ( + // isTourAnchor=true no matter what in order to + // force active guide step outside of security solution (cases) + + ), + } + : {}), + }); + ``` + +- The **`useTourContext`** is used within anchor components, returning the state of the security tour + ``` + export interface TourContextValue { + activeStep: number; + endTourStep: (stepId: SecurityStepId) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; + isTourShown: (stepId: 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. + + 4 + + So we utilize the `useTourContext` to do the following check and increment the step in `handleAddToNewCaseClick`: + ``` + if (isTourShown(SecurityStepId.alertsCases) && activeStep === 4) { + incrementStep(SecurityStepId.alertsCases); + } + ``` + + In `SecurityStepId.alertsCases` step 5, the user needs to fill out the form and hit the "Create case" button in order to end the `alertsCases` portion the tour, so with the `afterCaseCreated` method we call `endTourStep(SecurityStepId.alertsCases)`. \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts similarity index 67% rename from x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts rename to x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts index ed0dfa6c76339..2bb68dff4646f 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export { - useTourContext, - TourContextProvider, - SECURITY_TOUR_ACTIVE_KEY, - SECURITY_TOUR_STEP_KEY, -} from './tour'; +export { useTourContext, TourContextProvider } from './tour'; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx new file mode 100644 index 0000000000000..faea94a1c37ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { of } from 'rxjs'; +import { TourContextProvider, useTourContext } from './tour'; +import { SecurityStepId, securityTourConfig } from './tour_config'; +import { useKibana } from '../../lib/kibana'; + +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => true, +})); + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + + return { + ...original, + useLocation: jest.fn().mockReturnValue({ pathname: '/alerts' }), + }; +}); + +describe('useTourContext', () => { + const mockCompleteGuideStep = jest.fn(); + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + guidedOnboarding: { + guidedOnboardingApi: { + isGuideStepActive$: () => of(true), + completeGuideStep: mockCompleteGuideStep, + }, + }, + }, + }); + jest.clearAllMocks(); + }); + // @ts-ignore + const stepIds = Object.values(SecurityStepId); + describe.each(stepIds)('%s', (stepId) => { + it('if guidedOnboardingApi?.isGuideStepActive$ is false, isTourShown should be false', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + guidedOnboarding: { + guidedOnboardingApi: { + isGuideStepActive$: () => of(false), + }, + }, + }, + }); + const { result } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + expect(result.current.isTourShown(stepId)).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); + }); + it('endTourStep calls completeGuideStep with correct stepId', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + await waitForNextUpdate(); + result.current.endTourStep(stepId); + expect(mockCompleteGuideStep).toHaveBeenCalledWith('security', stepId); + }); + }); + it('activeStep is initially 1', () => { + const { result } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + 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 () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTourContext(), { + wrapper: TourContextProvider, + }); + await waitForNextUpdate(); + const stepCount = securityTourConfig[stepId].length; + for (let i = 0; i < stepCount - 1; i++) { + result.current.incrementStep(stepId); + } + const lastStep = stepCount ? stepCount : 1; + expect(result.current.activeStep).toBe(lastStep); + result.current.incrementStep(stepId); + expect(result.current.activeStep).toBe(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx new file mode 100644 index 0000000000000..43f6ca15b33cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx @@ -0,0 +1,130 @@ +/* + * 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 type { ReactChild } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +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 { useKibana } from '../../lib/kibana'; +import { securityTourConfig, SecurityStepId } from './tour_config'; + +export interface TourContextValue { + activeStep: number; + endTourStep: (stepId: SecurityStepId) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; + isTourShown: (stepId: SecurityStepId) => boolean; +} + +const initialState: TourContextValue = { + activeStep: 0, + endTourStep: () => {}, + incrementStep: () => {}, + isTourShown: () => false, +}; + +const TourContext = createContext(initialState); + +export const RealTourContextProvider = ({ children }: { children: ReactChild }) => { + const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; + + const isRulesTourActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', SecurityStepId.rules).pipe( + // if no result after 30s the observable will error, but the error handler will just emit false + timeout(30000), + catchError((error) => of(false)) + ) ?? of(false), + false + ); + const isAlertsCasesTourActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', SecurityStepId.alertsCases).pipe( + // if no result after 30s the observable will error, but the error handler will just emit false + timeout(30000), + catchError((error) => of(false)) + ) ?? of(false), + false + ); + + const tourStatus = useMemo( + () => ({ + [SecurityStepId.rules]: isRulesTourActive, + [SecurityStepId.alertsCases]: isAlertsCasesTourActive, + }), + [isRulesTourActive, isAlertsCasesTourActive] + ); + + const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]); + const [activeStep, _setActiveStep] = useState(1); + + const incrementStep = useCallback((stepId: SecurityStepId) => { + _setActiveStep( + (prevState) => (prevState >= securityTourConfig[stepId].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 [completeStep, setCompleteStep] = useState(null); + + useEffect(() => { + if (!completeStep || !guidedOnboardingApi) { + return; + } + let ignore = false; + const complete = async () => { + await guidedOnboardingApi.completeGuideStep('security', completeStep); + if (!ignore) { + setCompleteStep(null); + _setActiveStep(1); + } + }; + complete(); + return () => { + ignore = true; + }; + }, [completeStep, guidedOnboardingApi]); + + const endTourStep = useCallback((stepId: SecurityStepId) => { + setCompleteStep(stepId); + }, []); + + const context = { + activeStep, + endTourStep, + incrementStep, + isTourShown, + }; + + return {children}; +}; + +export const TourContextProvider = ({ children }: { children: ReactChild }) => { + const { pathname } = useLocation(); + const isTourEnabled = useIsExperimentalFeatureEnabled('guidedOnboarding'); + + if (isDetectionsPath(pathname) && isTourEnabled) { + return {children}; + } + + return {children}; +}; + +export const useTourContext = (): TourContextValue => { + const ctx = useContext(TourContext); + if (!ctx) { + throw new Error('useTourContext can only be called inside of TourContext!'); + } + return ctx; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts new file mode 100644 index 0000000000000..f7ed05be4c418 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts @@ -0,0 +1,139 @@ +/* + * 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 type { EuiTourStepProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ElementTarget } from '@elastic/eui/src/services/findElement'; + +export const enum SecurityStepId { + rules = 'rules', + alertsCases = 'alertsCases', +} + +export type StepConfig = Pick< + EuiTourStepProps, + 'step' | 'content' | 'anchorPosition' | 'title' | 'initialFocus' | 'anchor' +> & { + anchor?: ElementTarget; + dataTestSubj: string; + hideNextButton?: boolean; + imageConfig?: { + altText: string; + src: string; + }; +}; + +const defaultConfig = { + minWidth: 360, + maxWidth: 360, + offset: 10, + repositionOnScroll: true, +}; + +export const getTourAnchor = (step: number, stepId: SecurityStepId) => + `tourStepAnchor-${stepId}-${step}`; + +const alertsCasesConfig: StepConfig[] = [ + { + ...defaultConfig, + step: 1, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourTitle', { + defaultMessage: 'Test alert for practice', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.ruleNameStep.tourContent', + { + defaultMessage: + 'To help you practice triaging alerts, we enabled a rule to create your first alert.', + } + ), + anchorPosition: 'downCenter', + dataTestSubj: getTourAnchor(1, SecurityStepId.alertsCases), + initialFocus: `button[tour-step="nextButton"]`, + }, + { + ...defaultConfig, + step: 2, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.openFlyout.tourTitle', { + defaultMessage: 'Review the alert details', + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.openFlyout.tourContent', + { + defaultMessage: + "Some information is provided at-a-glance in the table, but for full details, you'll want to open the alert.", + } + ), + anchorPosition: 'rightUp', + dataTestSubj: getTourAnchor(2, SecurityStepId.alertsCases), + hideNextButton: true, + }, + { + ...defaultConfig, + step: 3, + title: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.flyoutOverview.tourTitle', + { + defaultMessage: 'Explore alert details', + } + ), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.flyoutOverview.tourContent', + { + defaultMessage: + 'Learn more about alerts by checking out all the information available on each tab.', + } + ), + // needs to use anchor to properly place tour step + anchor: `[tour-step="${getTourAnchor(3, SecurityStepId.alertsCases)}"] .euiTabs`, + anchorPosition: 'leftUp', + dataTestSubj: getTourAnchor(3, SecurityStepId.alertsCases), + }, + { + ...defaultConfig, + step: 4, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.addToCase.tourTitle', { + defaultMessage: 'Create a case', + }), + content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.addToCase.tourContent', { + defaultMessage: 'From the Take action menu, add the alert to a new case.', + }), + anchorPosition: 'upRight', + dataTestSubj: getTourAnchor(4, SecurityStepId.alertsCases), + hideNextButton: true, + }, + { + ...defaultConfig, + step: 5, + title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.createCase.tourTitle', { + defaultMessage: `Add details`, + }), + content: i18n.translate( + 'xpack.securitySolution.guided_onboarding.tour.createCase.tourContent', + { + defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`, + } + ), + anchor: `[data-test-subj="create-case-flyout"]`, + anchorPosition: 'leftUp', + dataTestSubj: getTourAnchor(5, SecurityStepId.alertsCases), + hideNextButton: true, + }, +]; + +interface SecurityTourConfig { + [SecurityStepId.rules]: StepConfig[]; + [SecurityStepId.alertsCases]: StepConfig[]; +} + +export const securityTourConfig: SecurityTourConfig = { + /** + * D&R team implement your tour config here + */ + [SecurityStepId.rules]: [], + [SecurityStepId.alertsCases]: alertsCasesConfig, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx new file mode 100644 index 0000000000000..04f2cfd6a4311 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx @@ -0,0 +1,225 @@ +/* + * 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 { GuidedOnboardingTourStep, SecurityTourStep } from './tour_step'; +import { SecurityStepId } from './tour_config'; +import { useTourContext } from './tour'; + +jest.mock('./tour'); +const mockTourStep = jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( + {children} + )); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + EuiTourStep: (props: any) => mockTourStep(props), + }; +}); +const defaultProps = { + isTourAnchor: true, + step: 1, + stepId: SecurityStepId.alertsCases, +}; + +const mockChildren =

{'random child element'}

; + +describe('GuidedOnboardingTourStep', () => { + beforeEach(() => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + jest.clearAllMocks(); + }); + it('renders as a tour step', () => { + const { getByTestId } = render( + {mockChildren} + ); + const tourStep = getByTestId('tourStepMock'); + const header = getByTestId('h1'); + expect(tourStep).toBeInTheDocument(); + expect(header).toBeInTheDocument(); + }); + it('isTourAnchor={false}, just render children', () => { + const { getByTestId, queryByTestId } = render( + + {mockChildren} + + ); + const tourStep = queryByTestId('tourStepMock'); + const header = getByTestId('h1'); + expect(tourStep).not.toBeInTheDocument(); + expect(header).toBeInTheDocument(); + }); +}); + +describe('SecurityTourStep', () => { + const { isTourAnchor: _, ...securityTourStepDefaultProps } = defaultProps; + beforeEach(() => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + jest.clearAllMocks(); + }); + + it('does not render if tour step does not exist', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 99, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + render( + + {mockChildren} + + ); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('does not render if tour step does not equal active step', () => { + render( + + {mockChildren} + + ); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('does not render if security tour step is not shown', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 1, + incrementStep: jest.fn(), + isTourShown: () => false, + }); + render({mockChildren}); + expect(mockTourStep).not.toHaveBeenCalled(); + }); + + it('renders tour step with correct number of steppers', () => { + render({mockChildren}); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.step).toEqual(1); + expect(mockCall.stepsTotal).toEqual(5); + }); + + it('forces the render for step 5 of the SecurityStepId.alertsCases tour step', () => { + render( + + {mockChildren} + + ); + 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 ', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 3, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + render( + + {mockChildren} + + ); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.footerAction).toMatchInlineSnapshot(` + + + + `); + }); + + it('if a step has an anchor declared, the tour step should be a sibling of the mockChildren', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 3, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + const { container } = render( + + {mockChildren} + + ); + const selectParent = container.querySelector( + `[data-test-subj="tourStepMock"] [data-test-subj="h1"]` + ); + const selectSibling = container.querySelector( + `[data-test-subj="tourStepMock"]+[data-test-subj="h1"]` + ); + expect(selectSibling).toBeInTheDocument(); + expect(selectParent).not.toBeInTheDocument(); + }); + + it('if a step does not an anchor declared, the tour step should be the parent of the mockChildren', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: 2, + incrementStep: jest.fn(), + isTourShown: () => true, + }); + const { container } = render( + + {mockChildren} + + ); + const selectParent = container.querySelector( + `[data-test-subj="tourStepMock"] [data-test-subj="h1"]` + ); + const selectSibling = container.querySelector( + `[data-test-subj="tourStepMock"]+[data-test-subj="h1"]` + ); + expect(selectParent).toBeInTheDocument(); + expect(selectSibling).not.toBeInTheDocument(); + }); + + it('if a tour step does not have children and has anchor, only render tour step', () => { + const { getByTestId } = render(); + expect(getByTestId('tourStepMock')).toBeInTheDocument(); + }); + + it('if a tour step does not have children and does not have anchor, render nothing', () => { + const { queryByTestId } = render( + + ); + 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( + + {mockChildren} + + ); + const mockCall = { ...mockTourStep.mock.calls[0][0] }; + expect(mockCall.footerAction).toMatchInlineSnapshot(``); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx new file mode 100644 index 0000000000000..ef07c5ce44a42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx @@ -0,0 +1,116 @@ +/* + * 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, useMemo } from 'react'; + +import type { EuiTourStepProps } from '@elastic/eui'; +import { EuiButton, EuiImage, EuiSpacer, EuiText, EuiTourStep } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useTourContext } from './tour'; +import { securityTourConfig, SecurityStepId } from './tour_config'; +interface SecurityTourStep { + children?: React.ReactElement; + step: number; + stepId: SecurityStepId; +} + +export const SecurityTourStep = ({ children, step, stepId }: SecurityTourStep) => { + const { activeStep, incrementStep, isTourShown } = useTourContext(); + const tourStep = useMemo( + () => securityTourConfig[stepId].find((config) => config.step === step), + [step, stepId] + ); + const onClick = useCallback(() => incrementStep(stepId), [incrementStep, stepId]); + // step === 5 && stepId === SecurityStepId.alertsCases is in Cases app and 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 = step === 5 && stepId === SecurityStepId.alertsCases; + if (tourStep == null || ((step !== activeStep || !isTourShown(stepId)) && !overrideContext)) { + return children ? children : null; + } + + const { anchor, content, imageConfig, dataTestSubj, hideNextButton = false, ...rest } = tourStep; + + const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? ( + + + + ) : ( + <> + {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/} + + ); + + const commonProps = { + ...rest, + content: ( + <> + +

{content}

+
+ {imageConfig && ( + <> + + + + )} + + ), + footerAction, + // we would not have mounted this component if it was not open + isStepOpen: true, + // guided onboarding does not allow skipping tour through the steps + onFinish: () => null, + stepsTotal: securityTourConfig[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, + // } + }; + + // tour step either needs children or an anchor element + // see type EuiTourStepAnchorProps + return anchor != null ? ( + <> + + <>{children} + + ) : children != null ? ( + {children} + ) : null; +}; + +interface GuidedOnboardingTourStep extends SecurityTourStep { + // can be false if the anchor is an iterative element + // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway + isTourAnchor?: boolean; +} + +// wraps tour anchor component +// and gives the tour step itself a place to mount once it is active +// mounts the tour step with a delay to ensure the anchor will render first +export const GuidedOnboardingTourStep = ({ + children, + // can be false if the anchor is an iterative element + // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway + isTourAnchor = true, + ...props +}: GuidedOnboardingTourStep) => + isTourAnchor ? {children} : <>{children}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 5a99df01e5328..1d13d100b4d88 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -16,7 +16,7 @@ import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental import { TestProviders } from '../../../mock'; import { CASES_FEATURE_ID } from '../../../../../common/constants'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; -import { useTourContext } from '../../guided_onboarding'; +import { useTourContext } from '../../guided_onboarding_tour'; import { useUserPrivileges } from '../../user_privileges'; import { noCasesPermissions, @@ -38,7 +38,7 @@ jest.mock('../../../hooks/use_selector'); jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks'); -jest.mock('../../guided_onboarding'); +jest.mock('../../guided_onboarding_tour'); jest.mock('../../user_privileges'); const mockUseUserPrivileges = useUserPrivileges as jest.Mock; @@ -187,25 +187,4 @@ describe('useSecuritySolutionNavigation', () => { }); }); }); - - describe('Guided onboarding tour', () => { - it('nav can be collapsed if tour is not shown', () => { - const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( - () => useSecuritySolutionNavigation(), - { wrapper: TestProviders } - ); - - expect(result.current?.canBeCollapsed).toBe(true); - }); - it(`nav can't be collapsed if tour is shown`, () => { - (useTourContext as jest.Mock).mockReturnValue({ isTourShown: true }); - - const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( - () => useSecuritySolutionNavigation(), - { wrapper: TestProviders } - ); - - expect(result.current?.canBeCollapsed).toBe(false); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 9e83ae9339dcd..647193357b66b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -13,7 +13,6 @@ import type { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; import { useIsGroupedNavigationEnabled } from '../helpers'; import { SecuritySideNav } from '../security_side_nav'; -import { useTourContext } from '../../guided_onboarding'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -31,8 +30,6 @@ export const usePrimaryNavigation = ({ const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); - const { isTourShown } = useTourContext(); - useEffect(() => { const currentTabSelected = mapLocationToTab(); @@ -49,7 +46,7 @@ export const usePrimaryNavigation = ({ }); return { - canBeCollapsed: !isTourShown, + canBeCollapsed: true, name: translatedNavTitle, icon: 'logoSecurity', ...(isGroupedNavigationEnabled diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 5d2fed9fc6241..efa9ce4831be7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -44,6 +44,7 @@ import { noCasesPermissions } from '../../../cases_test_utils'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { mockApm } from '../apm/service.mock'; import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; +import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; const mockUiSettings: Record = { [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, @@ -106,6 +107,7 @@ export const createStartServicesMock = ( cases.helpers.getUICapabilities.mockReturnValue(noCasesPermissions()); const triggersActionsUi = triggersActionsUiMock.createStart(); const cloudExperiments = cloudExperimentsMock.createStartMock(); + const guidedOnboarding = guidedOnboardingMock.createStart(); return { ...core, @@ -173,6 +175,7 @@ export const createStartServicesMock = ( }, triggersActionsUi, cloudExperiments, + guidedOnboarding, } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 538ae5b4ba211..70455fa342ab5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -9,6 +9,9 @@ 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 { SecurityStepId } from '../../../../common/components/guided_onboarding_tour/tour_config'; +import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import type { TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { Ecs } from '../../../../../common/ecs'; @@ -53,9 +56,18 @@ export const useAddToCaseActions = ({ : []; }, [casesUi.helpers, ecsData, nonEcsData]); + const { activeStep, endTourStep, incrementStep, isTourShown } = useTourContext(); + + const afterCaseCreated = useCallback(async () => { + if (isTourShown(SecurityStepId.alertsCases)) { + endTourStep(SecurityStepId.alertsCases); + } + }, [endTourStep, isTourShown]); + const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ onClose: onMenuItemClick, onSuccess, + afterCaseCreated, }); const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ @@ -66,8 +78,22 @@ export const useAddToCaseActions = ({ const handleAddToNewCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` onMenuItemClick(); - createCaseFlyout.open({ attachments: caseAttachments }); - }, [onMenuItemClick, createCaseFlyout, caseAttachments]); + createCaseFlyout.open({ + attachments: caseAttachments, + ...(isTourShown(SecurityStepId.alertsCases) && activeStep === 4 + ? { + headerContent: ( + // isTourAnchor=true no matter what in order to + // force active guide step outside of security solution (cases) + + ), + } + : {}), + }); + if (isTourShown(SecurityStepId.alertsCases) && activeStep === 4) { + incrementStep(SecurityStepId.alertsCases); + } + }, [onMenuItemClick, createCaseFlyout, caseAttachments, isTourShown, activeStep, incrementStep]); const handleAddToExistingCaseClick = useCallback(() => { // TODO rename this, this is really `closePopover()` diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index f4364443a6dfa..f9d5a5bc998c0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -8,6 +8,8 @@ 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 { isActiveTimeline } from '../../../helpers'; import { TableId } from '../../../../common/types'; import { useResponderActionItem } from '../endpoint_responder'; @@ -252,19 +254,24 @@ export const TakeActionDropdown = React.memo( ] ); - const takeActionButton = useMemo(() => { - return ( - - {TAKE_ACTION} - - ); - }, [togglePopoverHandler]); + const takeActionButton = useMemo( + () => ( + + + {TAKE_ACTION} + + + ), + + [togglePopoverHandler] + ); + return items.length && !loadingEventDetails && ecsData ? ( = ({ - browserFields, - columnId, - data, - ecsData, - eventId, - globalFilters, - header, - isDetails, - isDraggable, - isExpandable, - isExpanded, - linkValues, - rowIndex, - colIndex, - rowRenderers, - setCellProps, - scopeId, - truncate, -}) => ( - -); +export const RenderCellValue: React.FC = ( + props +) => { + const { columnId, rowIndex, scopeId } = props; + const isTourAnchor = useMemo( + () => + columnId === SIGNAL_RULE_NAME_FIELD_NAME && + isDetectionsAlertsTable(scopeId) && + rowIndex === 0, + [columnId, rowIndex, scopeId] + ); + + return ( + + + + ); +}; export const useRenderCellValue = ({ setFlyoutAlert, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 2c1b5b06a284e..a0384d7707534 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -12,6 +12,10 @@ import { noop } from 'lodash/fp'; import styled from 'styled-components'; 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 { getScopedActions, isTimelineScope } from '../../../../../helpers'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import { eventHasNotes, getEventType, getPinOnClick } from '../helpers'; @@ -201,6 +205,24 @@ const ActionsComponent: React.FC = ({ scopedActions, ]); + const { isTourShown, incrementStep } = useTourContext(); + + const isTourAnchor = useMemo( + () => + isTourShown(SecurityStepId.alertsCases) && + eventType === 'signal' && + isDetectionsAlertsTable(timelineId) && + ariaRowindex === 1, + [isTourShown, ariaRowindex, eventType, timelineId] + ); + + const onExpandEvent = useCallback(() => { + if (isTourAnchor) { + incrementStep(SecurityStepId.alertsCases); + } + onEventDetailsPanelOpened(); + }, [incrementStep, isTourAnchor, onEventDetailsPanelOpened]); + return ( {showCheckboxes && !tGridEnabled && ( @@ -220,19 +242,25 @@ const ActionsComponent: React.FC = ({ )} -
- - - - - -
+ +
+ + + + + +
+
<> {timelineId !== TimelineId.active && ( Promise>(asyncNoop); const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(new Subscription()); - const [loading, setLoading] = useState(false); + + // loading = false initial state causes flashes of empty tables + const [loading, setLoading] = useState(true); const [timelineDetailsRequest, setTimelineDetailsRequest] = useState(null); const { addError, addWarning } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index a5f8e5897230d..70a5de2c00af6 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -41,6 +41,7 @@ import type { } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { ThreatIntelligencePluginStart } from '@kbn/threat-intelligence-plugin/public'; import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; +import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -76,6 +77,7 @@ export interface StartPlugins { embeddable: EmbeddableStart; inspector: InspectorStart; fleet?: FleetStart; + guidedOnboarding: GuidedOnboardingPluginStart; kubernetesSecurity: KubernetesSecurityStart; lens: LensPublicStart; lists?: ListsPluginStart; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 73cc46c87a0a8..283ffe2b84854 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -28234,21 +28234,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "Inconnu", "xpack.securitySolution.globalHeader.buttonAddData": "Ajouter des intégrations", "xpack.securitySolution.goToDocumentationButton": "Afficher la documentation", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "Terminer la visite", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "Suivant", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "Ignorer la visite", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "Démonstration des alertes", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "Sachez quand les conditions d'une règle sont remplies, afin de pouvoir commencer votre investigation immédiatement. Configurez des notifications avec des plateformes tierces telles que Slack, PagerDuty et ServiceNow.", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "Soyez informé en cas de modification", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "Démonstration des cas", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "Recueillez des éléments probants, ajoutez des collaborateurs et transmettez même les détails de l'affaire à des systèmes tiers de gestion des cas.", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "Créez un cas pour suivre votre investigation", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "Recueillez des données à partir de vos points de terminaison en utilisant l'agent Elastic et une variété d'intégrations tierces.", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "Commencez à collecter vos données !", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "Décidez de ce qui est important pour vous et votre environnement, et créez des règles pour détecter et prévenir les activités malveillantes. ", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "Protégez votre écosystème", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "Faites une visite rapide pour découvrir un flux de travail unifié pour enquêter sur les activités suspectes.", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "Bienvenue dans Elastic Security", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "Soumettre l’action de réponse", "xpack.securitySolution.header.editableTitle.cancel": "Annuler", "xpack.securitySolution.header.editableTitle.save": "Enregistrer", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0cc4a0683b366..671be94fced60 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -28209,21 +28209,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "不明", "xpack.securitySolution.globalHeader.buttonAddData": "統合の追加", "xpack.securitySolution.goToDocumentationButton": "ドキュメンテーションを表示", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "ツアーを終了", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "次へ", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "ツアーをスキップ", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "アラートデモ", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "ルールの条件が満たされているときを把握し、調査をすぐに開始できるようにします。Slack、PagerDuty、ServiceNowなどのサードパーティプラットフォームで通知を設定します。", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "変更が発生したときに通知", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "ケースのデモ", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "エビデンスを収集し、その他のコラボレーターを追加し、さらにケース詳細をサードパーティケース管理システムにプッシュします。", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "調査を追跡するには、ケースを作成します", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "Elasticエージェントとさまざまなサードパーティ統合を使用して、エンドポイントからデータを収集します。", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "データの収集を開始してください。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "重要な項目と環境を決定し、悪意のあるアクティビティを検出および防御するルールを作成します。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "エコシステムを保護", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "不審なアクティビティの調査については、統合ワークフローを説明するクイックガイドを表示してください。", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "Elastic Securityへようこそ", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "対応アクションを送信", "xpack.securitySolution.header.editableTitle.cancel": "キャンセル", "xpack.securitySolution.header.editableTitle.save": "保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d9d116ef0aeb6..a88ad972ae72e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -28243,21 +28243,7 @@ "xpack.securitySolution.getCurrentUser.unknownUser": "未知", "xpack.securitySolution.globalHeader.buttonAddData": "添加集成", "xpack.securitySolution.goToDocumentationButton": "查看文档", - "xpack.securitySolution.guided_onboarding.endTour.buttonLabel": "结束教程", "xpack.securitySolution.guided_onboarding.nextStep.buttonLabel": "下一步", - "xpack.securitySolution.guided_onboarding.skipTour.buttonLabel": "跳过教程", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.imageAltText": "告警演示", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent": "知道何时满足规则条件,以便您立即开始调查。通过 Slack、PagerDuty 和 ServiceNow 等第三方平台设置通知。", - "xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle": "发生更改时接收通知", - "xpack.securitySolution.guided_onboarding.tour.casesStep.imageAltText": "案例演示", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent": "收集证据,添加更多协作者,甚至将案例详情推送到第三方案例管理系统。", - "xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle": "创建案例以跟踪您的调查", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent": "使用 Elastic 代理和一系列第三方集成从您的终端收集数据。", - "xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle": "开始收集您的数据!", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent": "确定对您和您的环境至关重要的事项,并创建规则来检测并防止恶意活动。", - "xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle": "保护您的生态系统", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent": "学习快速教程以浏览调查可疑活动的统一工作流。", - "xpack.securitySolution.guided_onboarding.tour.overviewStep.tourTitle": "欢迎使用 Elastic Security", "xpack.securitySolution.handleInputAreaState.inputPlaceholderText": "提交响应操作", "xpack.securitySolution.header.editableTitle.cancel": "取消", "xpack.securitySolution.header.editableTitle.save": "保存",