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 index 1056285ec646f..43f6ca15b33cb 100644 --- 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 @@ -19,8 +19,7 @@ import { securityTourConfig, SecurityStepId } from './tour_config'; export interface TourContextValue { activeStep: number; endTourStep: (stepId: SecurityStepId) => void; - incrementStep: (stepId: SecurityStepId) => void; - setActiveStep: (stepId: SecurityStepId, step: number) => void; + incrementStep: (stepId: SecurityStepId, step?: number) => void; isTourShown: (stepId: SecurityStepId) => boolean; } @@ -28,7 +27,6 @@ const initialState: TourContextValue = { activeStep: 0, endTourStep: () => {}, incrementStep: () => {}, - setActiveStep: () => {}, isTourShown: () => false, }; @@ -65,12 +63,6 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) const isTourShown = useCallback((stepId: SecurityStepId) => tourStatus[stepId], [tourStatus]); const [activeStep, _setActiveStep] = useState(1); - const setActiveStep = useCallback((stepId: SecurityStepId, step: number) => { - if (step <= securityTourConfig[stepId].length) { - _setActiveStep(step); - } - }, []); - const incrementStep = useCallback((stepId: SecurityStepId) => { _setActiveStep( (prevState) => (prevState >= securityTourConfig[stepId].length ? 0 : prevState) + 1 @@ -113,7 +105,6 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild }) endTourStep, incrementStep, isTourShown, - setActiveStep, }; return {children}; 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 index 7c506024e4ecd..f7ed05be4c418 100644 --- 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 @@ -125,39 +125,6 @@ const alertsCasesConfig: StepConfig[] = [ }, ]; -const rulesConfig: StepConfig[] = [ - { - ...defaultConfig, - title: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', - { defaultMessage: 'Load the Elastic prebuilt rules' } - ), - content: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', - { defaultMessage: 'To get started you need to load the Elastic prebuilt rules.' } - ), - step: 1, - anchorPosition: 'downCenter', - hideNextButton: true, - dataTestSubj: getTourAnchor(1, SecurityStepId.rules), - }, - { - ...defaultConfig, - title: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', - { defaultMessage: 'Search for Elastic Defend rules' } - ), - content: i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', - { defaultMessage: 'Find the rules you want and enable them.' } - ), - step: 2, - anchorPosition: 'upCenter', - hideNextButton: true, - dataTestSubj: getTourAnchor(2, SecurityStepId.rules), - }, -]; - interface SecurityTourConfig { [SecurityStepId.rules]: StepConfig[]; [SecurityStepId.alertsCases]: StepConfig[]; @@ -167,6 +134,6 @@ export const securityTourConfig: SecurityTourConfig = { /** * D&R team implement your tour config here */ - [SecurityStepId.rules]: rulesConfig, + [SecurityStepId.rules]: [], [SecurityStepId.alertsCases]: alertsCasesConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx deleted file mode 100644 index 934eccd618594..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_rules_tour.tsx +++ /dev/null @@ -1,50 +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 { useCallback, useEffect } from 'react'; -import { useTourContext } from './tour'; -import type { RulesQueryResponse } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; -import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query'; -import { SecurityStepId } from './tour_config'; -const GUIDED_ONBOARDING_RULES_FILTER = { - filter: '', - showCustomRules: false, - showElasticRules: true, - tags: ['Guided Onboarding'], -}; -export const useRulesTour = () => { - const { isTourShown, endTourStep, incrementStep, activeStep, setActiveStep } = useTourContext(); - const { data: onboardingRules } = useFindRulesQuery( - { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, - { retry: false, enabled: isTourShown(SecurityStepId.rules) } - ); - - const manageRulesTour = useCallback( - ({ rules }: RulesQueryResponse) => { - if (rules && rules.length === 0 && isTourShown(SecurityStepId.rules) && activeStep === 2) { - // reset to 1 if they are on step 2 but have no onboarding rules - setActiveStep(SecurityStepId.rules, 1); - } - if (rules && rules.length > 0 && isTourShown(SecurityStepId.rules) && activeStep === 1) { - // There are onboarding rules now, advance to step 2 if on step 1 - incrementStep(SecurityStepId.rules); - } - if (rules.some((rule) => rule.enabled)) { - // The onboarding rule is enabled, end the tour - endTourStep(SecurityStepId.rules); - } - }, - [activeStep, endTourStep, incrementStep, isTourShown, setActiveStep] - ); - - useEffect(() => { - if (onboardingRules) { - manageRulesTour(onboardingRules); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onboardingRules]); -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx new file mode 100644 index 0000000000000..1fcb56a009edb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/rules_management_tour.tsx @@ -0,0 +1,118 @@ +/* + * 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 { EuiTourActions, EuiTourStepProps } from '@elastic/eui'; +import { EuiTourStep } from '@elastic/eui'; +import { noop } from 'lodash'; +import React, { useEffect, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useFindRulesQuery } from '../../../rule_management/api/hooks/use_find_rules_query'; +import * as i18n from './translations'; +import { useIsElementMounted } from './use_is_element_mounted'; + +export const INSTALL_PREBUILT_RULES_ANCHOR = 'install-prebuilt-rules-anchor'; +export const SEARCH_FIRST_RULE_ANCHOR = 'search-first-rule-anchor'; + +export interface RulesFeatureTourContextType { + steps: EuiTourStepProps[]; + actions: EuiTourActions; +} + +const GUIDED_ONBOARDING_RULES_FILTER = { + filter: '', + showCustomRules: false, + showElasticRules: true, + tags: ['Guided Onboarding'], +}; + +export enum GuidedOnboardingRulesStatus { + 'inactive' = 'inactive', + 'installRules' = 'installRules', + 'activateRules' = 'activateRules', + 'completed' = 'completed', +} + +export const RulesManagementTour = () => { + const { guidedOnboardingApi } = useKibana().services.guidedOnboarding; + + const isRulesStepActive = useObservable( + guidedOnboardingApi?.isGuideStepActive$('security', 'rules') ?? of(false), + false + ); + + const { data: onboardingRules } = useFindRulesQuery( + { filterOptions: GUIDED_ONBOARDING_RULES_FILTER }, + { enabled: isRulesStepActive } + ); + + const tourStatus = useMemo(() => { + if (!isRulesStepActive || !onboardingRules) { + return GuidedOnboardingRulesStatus.inactive; + } + + if (onboardingRules.total === 0) { + // Onboarding rules are not installed - show the install/update rules step + return GuidedOnboardingRulesStatus.installRules; + } + + if (!onboardingRules.rules.some((rule) => rule.enabled)) { + // None of the onboarding rules is active - show the activate step + return GuidedOnboardingRulesStatus.activateRules; + } + + // Rules are installed and enabled - the tour is completed + return GuidedOnboardingRulesStatus.completed; + }, [isRulesStepActive, onboardingRules]); + + // Synchronize the current "internal" tour step with the global one + useEffect(() => { + if (isRulesStepActive && tourStatus === GuidedOnboardingRulesStatus.completed) { + guidedOnboardingApi?.completeGuideStep('security', 'rules'); + } + }, [guidedOnboardingApi, isRulesStepActive, tourStatus]); + + /** + * Wait until the tour target elements are visible on the page and mount + * EuiTourStep components only after that. Otherwise, the tours would never + * show up on the page. + */ + const isInstallRulesAnchorMounted = useIsElementMounted(INSTALL_PREBUILT_RULES_ANCHOR); + const isSearchFirstRuleAnchorMounted = useIsElementMounted(SEARCH_FIRST_RULE_ANCHOR); + + return ( + <> + {isInstallRulesAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + {isSearchFirstRuleAnchorMounted && ( + } // Replace "Skip tour" with an empty element + /> + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts new file mode 100644 index 0000000000000..6c8a2880801a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const INSTALL_PREBUILT_RULES_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title', + { + defaultMessage: 'Load the Elastic prebuilt rules', + } +); + +export const INSTALL_PREBUILT_RULES_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content', + { + defaultMessage: 'To get started you need to load the Elastic prebuilt rules.', + } +); + +export const SEARCH_FIRST_RULE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.title', + { + defaultMessage: 'Search for Elastic Defend rules', + } +); + +export const SEARCH_FIRST_RULE_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.guidedOnboarding.searchFirstRule.content', + { + defaultMessage: 'Find the My First Alert rule and enable it.', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts new file mode 100644 index 0000000000000..b3be0184e1a3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/guided_onboarding/use_is_element_mounted.ts @@ -0,0 +1,35 @@ +/* + * 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 { useEffect, useState } from 'react'; + +export const useIsElementMounted = (elementId: string) => { + const [isElementMounted, setIsElementMounted] = useState(false); + + useEffect(() => { + const observer = new MutationObserver(() => { + const isElementFound = !!document.getElementById(elementId); + + if (isElementFound && !isElementMounted) { + setIsElementMounted(true); + } + + if (!isElementFound && isElementMounted) { + setIsElementMounted(false); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [isElementMounted, elementId]); + + return isElementMounted; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx index c32f00b1b53a3..143ae37a694d1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx @@ -15,8 +15,6 @@ import { import { isEqual } from 'lodash/fp'; import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step'; -import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config'; import { RULES_TABLE_ACTIONS } from '../../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction'; import { usePrePackagedRulesStatus } from '../../../../rule_management/logic/use_pre_packaged_rules_status'; @@ -24,6 +22,7 @@ import * as i18n from '../../../../../detections/pages/detection_engine/rules/tr import { useRulesTableContext } from '../rules_table/rules_table_context'; import { TagsFilterPopover } from './tags_filter_popover'; import { useTags } from '../../../../rule_management/logic/use_tags'; +import { SEARCH_FIRST_RULE_ANCHOR } from '../../guided_onboarding/rules_management_tour'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; @@ -86,15 +85,14 @@ const RulesTableFiltersComponent = () => { return ( - - - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index 848653c1a95e7..e49c6b61a0019 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { useRulesTour } from '../../../../common/components/guided_onboarding_tour/use_rules_tour'; import { APP_UI_ID } from '../../../../../common/constants'; import { SecurityPageName } from '../../../../app/types'; import { HeaderPage } from '../../../../common/components/header_page'; @@ -41,6 +40,7 @@ import { AllRules } from '../../components/rules_table'; import { RulesTableContextProvider } from '../../components/rules_table/rules_table/rules_table_context'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; +import { RulesManagementTour } from '../../components/guided_onboarding/rules_management_tour'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -65,7 +65,7 @@ const RulesPageComponent: React.FC = () => { const loading = userInfoLoading || listsConfigLoading; const prePackagedRuleStatus = usePrePackagedRulesInstallationStatus(); const prePackagedTimelineStatus = usePrePackagedTimelinesInstallationStatus(); - useRulesTour(); + if ( redirectToDetections( isSignalIndexExists, @@ -86,6 +86,7 @@ const RulesPageComponent: React.FC = () => { + +
{getLoadRulesOrTimelinesButtonTitle(prePackagedAssetsStatus, prePackagedTimelineStatus)} - +
); } @@ -85,9 +88,13 @@ export const LoadPrePackagedRulesButton = ({ prePackagedTimelineStatus === 'someTimelineUninstall'; if (showUpdateButton) { + // Without the outer div EuiStepTour crashes with Uncaught DOMException: + // Failed to execute 'removeChild' on 'Node': The node to be removed is not + // a child of this node. return ( - +
- +
); }