diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index fcec53eb0cf30..e1d0fbc67d001 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -419,3 +419,6 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrenc */ export const RULES_TABLE_MAX_PAGE_SIZE = 100; export const RULES_TABLE_PAGE_SIZE_OPTIONS = [5, 10, 20, 50, RULES_TABLE_MAX_PAGE_SIZE]; + +export const RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY = + 'securitySolution.rulesManagementPage.newFeaturesTour.v8.1'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index ad6ad0486e518..349f2aaf32732 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -9,6 +9,7 @@ import * as yaml from 'js-yaml'; import Url, { UrlObject } from 'url'; import { ROLES } from '../../common/test'; +import { RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY } from '../../common/constants'; import { TIMELINE_FLYOUT_BODY } from '../screens/timeline'; import { hostDetailsUrl, LOGOUT_URL } from '../urls/navigation'; @@ -284,6 +285,21 @@ export const getEnvAuth = (): User => { } }; +/** + * Saves in localStorage rules feature tour config with deactivated option + * It prevents tour to appear during tests and cover UI elements + * @param window - browser's window object + */ +const disableRulesFeatureTour = (window: Window) => { + const tourConfig = { + isTourActive: false, + }; + window.localStorage.setItem( + RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY, + JSON.stringify(tourConfig) + ); +}; + /** * Authenticates with Kibana, visits the specified `url`, and waits for the * Kibana global nav to be displayed before continuing @@ -301,6 +317,7 @@ export const loginAndWaitForPage = ( if (onBeforeLoadCallback) { onBeforeLoadCallback(win); } + disableRulesFeatureTour(win); }, } ); @@ -315,13 +332,17 @@ export const waitForPage = (url: string) => { export const loginAndWaitForPageWithoutDateRange = (url: string, role?: ROLES) => { login(role); - cy.visit(role ? getUrlWithRoute(role, url) : url); + cy.visit(role ? getUrlWithRoute(role, url) : url, { + onBeforeLoad: disableRulesFeatureTour, + }); cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); }; export const loginWithUserAndWaitForPageWithoutDateRange = (url: string, user: User) => { loginWithUser(user); - cy.visit(constructUrlWithUser(user, url)); + cy.visit(constructUrlWithUser(user, url), { + onBeforeLoad: disableRulesFeatureTour, + }); cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); }; @@ -329,7 +350,9 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { const route = `/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`; login(role); - cy.visit(role ? getUrlWithRoute(role, route) : route); + cy.visit(role ? getUrlWithRoute(role, route) : route, { + onBeforeLoad: disableRulesFeatureTour, + }); cy.get('[data-test-subj="headerGlobalNav"]'); cy.get(TIMELINE_FLYOUT_BODY).should('be.visible'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx index faa4733a0bf3e..aa07a4442fab7 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx @@ -35,11 +35,17 @@ const Popover = React.memo( ownFocus, dataTestSubj, popoverPanelPaddingSize, + onClick, }) => { const [popoverState, setPopoverState] = useState(false); const closePopover = useCallback(() => setPopoverState(false), [setPopoverState]); + const handleLinkIconClick = useCallback(() => { + onClick?.(); + setPopoverState(!popoverState); + }, [popoverState, onClick]); + return ( ( iconSide={iconSide} iconSize={iconSize} iconType={iconType} - onClick={() => setPopoverState(!popoverState)} + onClick={handleLinkIconClick} disabled={disabled} > {children} } - closePopover={() => setPopoverState(false)} + closePopover={closePopover} isOpen={popoverState} repositionOnScroll > @@ -107,6 +113,7 @@ export const UtilityBarAction = React.memo( {popoverContent ? ( void; - onConfirm: (bulkactionEditPayload: BulkActionEditPayload) => void; + onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void; editAction: BulkActionEditType; rulesCount: number; tags: string[]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 3b24dda539174..6092ec2a134d1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -13,6 +13,7 @@ import { TestProviders } from '../../../../../common/mock'; import '../../../../../common/mock/formatted_relative'; import '../../../../../common/mock/match_media'; import { AllRules } from './index'; +import { RulesFeatureTourContextProvider } from './rules_feature_tour_context'; jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../../common/lib/kibana'); @@ -67,7 +68,8 @@ describe('AllRules', () => { rulesNotInstalled={0} rulesNotUpdated={0} /> - + , + { wrappingComponent: RulesFeatureTourContextProvider } ); await waitFor(() => { @@ -90,7 +92,8 @@ describe('AllRules', () => { rulesNotInstalled={0} rulesNotUpdated={0} /> - + , + { wrappingComponent: RulesFeatureTourContextProvider } ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index e8c7742125c74..6bb9927c8ab82 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -45,7 +45,7 @@ export const AllRules = React.memo( return ( <> - + = ({ + children, + stepProps, +}) => { + if (!stepProps) { + return <>{children}; + } + + return ( + + <>{children} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx new file mode 100644 index 0000000000000..6c1d5a0de7a54 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_feature_tour_context.tsx @@ -0,0 +1,141 @@ +/* + * 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, { createContext, useContext, useEffect, useMemo, FC } from 'react'; + +import { noop } from 'lodash'; +import { + useEuiTour, + EuiTourState, + EuiStatelessTourStep, + EuiSpacer, + EuiButton, + EuiTourStepProps, +} from '@elastic/eui'; +import { invariant } from '../../../../../../common/utils/invariant'; +import { RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY } from '../../../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; + +import * as i18n from '../translations'; + +export interface RulesFeatureTourContextType { + steps: { + inMemoryTableStepProps: EuiTourStepProps; + bulkActionsStepProps: EuiTourStepProps; + }; + goToNextStep: () => void; + finishTour: () => void; +} + +const TOUR_POPOVER_WIDTH = 360; + +const featuresTourSteps: EuiStatelessTourStep[] = [ + { + step: 1, + title: i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP_TITLE, + content: <>, + stepsTotal: 2, + children: <>, + onFinish: noop, + maxWidth: TOUR_POPOVER_WIDTH, + }, + { + step: 2, + title: i18n.FEATURE_TOUR_BULK_ACTIONS_STEP_TITLE, + content:

{i18n.FEATURE_TOUR_BULK_ACTIONS_STEP}

, + stepsTotal: 2, + children: <>, + onFinish: noop, + anchorPosition: 'rightUp', + maxWidth: TOUR_POPOVER_WIDTH, + }, +]; + +const tourConfig: EuiTourState = { + currentTourStep: 1, + isTourActive: true, + tourPopoverWidth: TOUR_POPOVER_WIDTH, + tourSubtitle: i18n.FEATURE_TOUR_TITLE, +}; + +const RulesFeatureTourContext = createContext(null); + +/** + * Context for new rules features, displayed in demo tour(euiTour) + * It has a common state in useEuiTour, which allows transition from one step to the next, for components within it[context] + * It also stores tour's state in localStorage + */ +export const RulesFeatureTourContextProvider: FC = ({ children }) => { + const { storage } = useKibana().services; + const initialStore = useMemo( + () => ({ + ...tourConfig, + ...(storage.get(RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY) ?? tourConfig), + }), + [storage] + ); + + const [stepProps, actions, reducerState] = useEuiTour(featuresTourSteps, initialStore); + + const finishTour = actions.finishTour; + const goToNextStep = actions.incrementStep; + + const inMemoryTableStepProps = useMemo( + () => ({ + ...stepProps[0], + content: ( + <> +

{i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP}

+ + + {i18n.FEATURE_TOUR_IN_MEMORY_TABLE_STEP_NEXT} + + + ), + }), + [stepProps, goToNextStep] + ); + + useEffect(() => { + const { isTourActive, currentTourStep } = reducerState; + storage.set(RULES_MANAGEMENT_FEATURE_TOUR_STORAGE_KEY, { isTourActive, currentTourStep }); + }, [reducerState, storage]); + + const providerValue = useMemo( + () => ({ + steps: { + inMemoryTableStepProps, + bulkActionsStepProps: stepProps[1], + }, + finishTour, + goToNextStep, + }), + [finishTour, goToNextStep, inMemoryTableStepProps, stepProps] + ); + + return ( + + {children} + + ); +}; + +export const useRulesFeatureTourContext = (): RulesFeatureTourContextType => { + const rulesFeatureTourContext = useContext(RulesFeatureTourContext); + invariant( + rulesFeatureTourContext, + 'useRulesFeatureTourContext should be used inside RulesFeatureTourContextProvider' + ); + + return rulesFeatureTourContext; +}; + +export const useRulesFeatureTourContextOptional = (): RulesFeatureTourContextType | null => { + const rulesFeatureTourContext = useContext(RulesFeatureTourContext); + + return rulesFeatureTourContext; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx index 261e14fd1411b..966cb726c8711 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_toolbar.tsx @@ -10,6 +10,8 @@ import React from 'react'; import styled from 'styled-components'; import { useRulesTableContext } from './rules_table/rules_table_context'; import * as i18n from '../translations'; +import { useRulesFeatureTourContext } from './rules_feature_tour_context'; +import { OptionalEuiTourStep } from './optional_eui_tour_step'; const ToolbarLayout = styled.div` display: grid; @@ -22,6 +24,7 @@ const ToolbarLayout = styled.div` interface RulesTableToolbarProps { activeTab: AllRulesTabs; onTabChange: (tab: AllRulesTabs) => void; + loading: boolean; } export enum AllRulesTabs { @@ -43,12 +46,17 @@ const allRulesTabs = [ ]; export const RulesTableToolbar = React.memo( - ({ onTabChange, activeTab }) => { + ({ onTabChange, activeTab, loading }) => { const { state: { isInMemorySorting }, actions: { setIsInMemorySorting }, } = useRulesTableContext(); + const { + steps: { inMemoryTableStepProps }, + goToNextStep, + } = useRulesFeatureTourContext(); + return ( @@ -64,13 +72,22 @@ export const RulesTableToolbar = React.memo( ))} - - setIsInMemorySorting(e.target.checked)} - /> - + {/* delaying render of tour due to EuiPopover can't react to layout changes + https://github.com/elastic/kibana/pull/124343#issuecomment-1032467614 */} + + + { + if (inMemoryTableStepProps.isStepOpen) { + goToNextStep(); + } + setIsInMemorySorting(e.target.checked); + }} + /> + + ); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 6d9c2f92b214e..a936e84cee00a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -22,6 +22,9 @@ import { UtilityBarText, } from '../../../../../common/components/utility_bar'; import * as i18n from '../translations'; +import { useRulesFeatureTourContextOptional } from './rules_feature_tour_context'; + +import { OptionalEuiTourStep } from './optional_eui_tour_step'; interface AllRulesUtilityBarProps { canBulkEdit: boolean; @@ -55,6 +58,9 @@ export const AllRulesUtilityBar = React.memo( isBulkActionInProgress, hasDisabledActions, }) => { + // use optional rulesFeatureTourContext as AllRulesUtilityBar can be used outside the context + const featureTour = useRulesFeatureTourContextOptional(); + const handleGetBulkItemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element | null => { if (onGetBulkItemsPopoverContent != null) { @@ -134,17 +140,24 @@ export const AllRulesUtilityBar = React.memo( )} {canBulkEdit && ( - - {i18n.BATCH_ACTIONS} - + + { + if (featureTour?.steps?.bulkActionsStepProps?.isStepOpen) { + featureTour?.finishTour(); + } + }} + > + {i18n.BATCH_ACTIONS} + + )} { showExceptionsCheckBox showCheckBox /> - - - - - {loadPrebuiltRulesAndTemplatesButton && ( - {loadPrebuiltRulesAndTemplatesButton} - )} - {reloadPrebuiltRulesAndTemplatesButton && ( - {reloadPrebuiltRulesAndTemplatesButton} - )} - - + + + + + + {loadPrebuiltRulesAndTemplatesButton && ( + {loadPrebuiltRulesAndTemplatesButton} + )} + {reloadPrebuiltRulesAndTemplatesButton && ( + {reloadPrebuiltRulesAndTemplatesButton} + )} + + + + {i18n.UPLOAD_VALUE_LISTS} + + + + - {i18n.UPLOAD_VALUE_LISTS} + {i18n.IMPORT_RULE} - - - - - {i18n.IMPORT_RULE} - - - - - {i18n.ADD_NEW_RULE} - - - - - {(prePackagedRuleStatus === 'ruleNeedUpdate' || - prePackagedTimelineStatus === 'timelineNeedUpdate') && ( - + + + {i18n.ADD_NEW_RULE} + + + + + {(prePackagedRuleStatus === 'ruleNeedUpdate' || + prePackagedTimelineStatus === 'timelineNeedUpdate') && ( + + )} + - )} - - - - + + + ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 1de060c16a97a..386e00fc28d8b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -88,6 +88,50 @@ export const EDIT_PAGE_TITLE = i18n.translate( } ); +export const FEATURE_TOUR_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.tourTitle', + { + defaultMessage: "What's new", + } +); + +export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepDescription', + { + defaultMessage: + 'The experimental rules table view allows for advanced sorting and filtering capabilities.', + } +); + +export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepTitle', + { + defaultMessage: 'Step 1', + } +); + +export const FEATURE_TOUR_IN_MEMORY_TABLE_STEP_NEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.inMemoryTableStepNextButtonTitle', + { + defaultMessage: 'Ok, got it', + } +); + +export const FEATURE_TOUR_BULK_ACTIONS_STEP_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.bulkActionsStepTitle', + { + defaultMessage: 'Step 2', + } +); + +export const FEATURE_TOUR_BULK_ACTIONS_STEP = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.featureTour.bulkActionsStepDescription', + { + defaultMessage: + 'You can now bulk update index patterns and tags for multiple custom rules at once.', + } +); + export const REFRESH = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle', {