From 13f2779e5a4de368a81457e8684e2292049d6ce2 Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Mon, 18 Dec 2023 23:01:16 +0100 Subject: [PATCH] [Security solution] Timeline tour compatiable with timeline template (#173526) ## Summary Handles https://github.com/elastic/kibana/issues/173355. Timeline v8.12 changes tour had some steps in error, if user changed from `default` timeline to `template` timeline. This PR makes step compatible both kinds of timeline. We have the ability to choose which step shows up on which kind of timeline. https://github.com/elastic/kibana/assets/7485038/80b96027-5e38-4e17-914d-d6df77a070f3 --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../timelines/components/timeline/index.tsx | 6 +- .../components/timeline/tour/index.test.tsx | 66 +++++++++++++++++-- .../components/timeline/tour/index.tsx | 27 +++++--- .../components/timeline/tour/step_config.tsx | 2 + 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index bffc5c45f84ef..0ebc326a35226 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -245,7 +245,11 @@ const StatefulTimelineComponent: React.FC = ({ {showTimelineTour ? ( - + ) : null} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.test.tsx index 68b6f33d8c085..8f902d47d6ed1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.test.tsx @@ -6,23 +6,44 @@ */ import React from 'react'; +import type { TimelineTourProps } from '.'; import { TimelineTour } from '.'; import { TIMELINE_TOUR_CONFIG_ANCHORS } from './step_config'; import { useIsElementMounted } from '../../../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { TestProviders } from '../../../../common/mock'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../../../common/mock'; import { TimelineTabs } from '../../../../../common/types'; +import { TimelineType } from '../../../../../common/api/timeline'; +import { createStore } from '../../../../common/store'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../../../common/lib/kibana'; jest.mock( '../../../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted' ); +jest.mock('../../../../common/lib/kibana'); + +const mockedUseKibana = mockUseKibana(); const switchTabMock = jest.fn(); +const { storage: storageMock } = createSecuritySolutionStorageMock(); +const mockStore = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storageMock); -const TestComponent = () => { +const TestComponent = (props: Partial = {}) => { return ( - - + + {Object.values(TIMELINE_TOUR_CONFIG_ANCHORS).map((anchor) => { return
; })} @@ -35,6 +56,18 @@ describe('Timeline Tour', () => { (useIsElementMounted as jest.Mock).mockReturnValue(true); }); + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + storage: storageMock, + }, + }); + + storageMock.clear(); + }); + it('should not render tour steps when element are not mounted', () => { (useIsElementMounted as jest.Mock).mockReturnValueOnce(false); render(); @@ -71,4 +104,29 @@ describe('Timeline Tour', () => { expect(screen.queryByText('Finish tour')).toBeVisible(); }); }); + + it('should render different tour steps when timeline type is template', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('timeline-tour-step-1')).toBeVisible(); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.getByTestId('timeline-tour-step-2')).toBeVisible(); + }); + + fireEvent.click(screen.getByText('Next')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-tour-step-3')).toBeVisible(); + }); + + fireEvent.click(screen.getByText('Next')); + + await waitFor(() => { + expect(screen.queryByText('Finish tour')).toBeVisible(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.tsx index 7285798688461..f959e6fe3a47a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/index.tsx @@ -10,8 +10,9 @@ * * */ -import React, { useEffect, useCallback, useState } from 'react'; +import React, { useEffect, useCallback, useState, useMemo } from 'react'; import { EuiButton, EuiButtonEmpty, EuiTourStep } from '@elastic/eui'; +import type { TimelineType } from '../../../../../common/api/timeline'; import type { TimelineTabs } from '../../../../../common/types'; import { useIsElementMounted } from '../../../../detection_engine/rule_management_ui/components/rules_table/rules_table/guided_onboarding/use_is_element_mounted'; import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../../../../../common/constants'; @@ -26,20 +27,26 @@ interface TourState { tourSubtitle: string; } -interface TimelineTourProps { +export interface TimelineTourProps { activeTab: TimelineTabs; + timelineType: TimelineType; switchToTab: (tab: TimelineTabs) => void; } const TimelineTourComp = (props: TimelineTourProps) => { - const { activeTab, switchToTab } = props; + const { activeTab, switchToTab, timelineType } = props; const { services: { storage }, } = useKibana(); + const updatedTourSteps = useMemo( + () => + timelineTourSteps.filter((step) => !step.timelineType || step.timelineType === timelineType), + [timelineType] + ); + const [tourState, setTourState] = useState(() => { const restoredTourState = storage.get(NEW_FEATURES_TOUR_STORAGE_KEYS.TIMELINE); - if (restoredTourState != null) { return restoredTourState; } @@ -71,7 +78,7 @@ const TimelineTourComp = (props: TimelineTourProps) => { const getFooterAction = useCallback( (step: number) => { // if it's the last step, we don't want to show the next button - return step === timelineTourSteps.length ? ( + return step === updatedTourSteps.length ? ( {i18n.TIMELINE_TOUR_FINISH} @@ -86,14 +93,14 @@ const TimelineTourComp = (props: TimelineTourProps) => { ] ); }, - [finishTour, nextStep] + [finishTour, nextStep, updatedTourSteps.length] ); - const nextEl = timelineTourSteps[tourState.currentTourStep - 1]?.anchor; + const nextEl = updatedTourSteps[tourState.currentTourStep - 1]?.anchor; const isElementAtCurrentStepMounted = useIsElementMounted(nextEl); - const currentStepConfig = timelineTourSteps[tourState.currentTourStep - 1]; + const currentStepConfig = updatedTourSteps[tourState.currentTourStep - 1]; if (currentStepConfig?.timelineTab && currentStepConfig.timelineTab !== activeTab) { switchToTab(currentStepConfig.timelineTab); @@ -105,7 +112,7 @@ const TimelineTourComp = (props: TimelineTourProps) => { return ( <> - {timelineTourSteps.map((steps, idx) => { + {updatedTourSteps.map((steps, idx) => { const stepCount = idx + 1; if (tourState.currentTourStep !== stepCount) return null; const panelProps = { @@ -118,7 +125,7 @@ const TimelineTourComp = (props: TimelineTourProps) => { step={stepCount} isStepOpen={tourState.isTourActive && tourState.currentTourStep === idx + 1} minWidth={tourState.tourPopoverWidth} - stepsTotal={timelineTourSteps.length} + stepsTotal={updatedTourSteps.length} onFinish={finishTour} title={steps.title} content={steps.content} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/step_config.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/step_config.tsx index 8b1e416324b1b..265f87a61d0bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/step_config.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tour/step_config.tsx @@ -8,6 +8,7 @@ import { EuiText, EuiCode } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { TimelineType } from '../../../../../common/api/timeline'; import { TimelineTabs } from '../../../../../common/types'; import * as i18n from './translations'; @@ -65,6 +66,7 @@ export const timelineTourSteps = [ anchor: TIMELINE_TOUR_CONFIG_ANCHORS.DATA_VIEW, }, { + timelineType: TimelineType.default, timelineTab: TimelineTabs.query, title: i18n.TIMELINE_TOUR_DATA_PROVIDER_VISIBILITY_TITLE, content: {i18n.TIMELINE_TOUR_DATA_PROVIDER_VISIBILITY_DESCRIPTION},