[Guided onboarding] Security tour fixes (#136958) (#137480)

* [Guided onboarding] Security tour: Fixed localStorage value parsing, also "add integrations" closes the tour

* [Guided onboarding] Security tour: Cypress tests

* [Guided onboarding] Security tour: Update text copy

* [Guided onboarding] Security tour: Added useCallback

(cherry picked from commit dcc835ec51)

Co-authored-by: Yulia Čech <6585477+yuliacech@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2022-07-28 12:45:28 -04:00 committed by GitHub
parent 114d1b8e10
commit 3fd7d9e7b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 233 additions and 34 deletions

View file

@ -0,0 +1,63 @@
/*
* 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 { 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,
MANAGE_STEP,
ALERTS_STEP,
CASES_STEP,
DATA_STEP,
} from '../../screens/guided_onboarding';
before(() => {
login();
});
describe('Guided onboarding tour', () => {
describe('Tour is enabled', () => {
beforeEach(() => {
visit(OVERVIEW_URL);
window.localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, 'true');
});
it('can be completed', () => {
// Step 1: Overview
cy.get(WELCOME_STEP).should('be.visible');
goToNextStep(WELCOME_STEP);
// Step 2: Manage
cy.get(MANAGE_STEP).should('be.visible');
goToNextStep(MANAGE_STEP);
// Step 3: Alerts
cy.get(ALERTS_STEP).should('be.visible');
goToNextStep(ALERTS_STEP);
// Step 4: Cases
cy.get(CASES_STEP).should('be.visible');
goToNextStep(CASES_STEP);
// Step 5: Add data
cy.get(DATA_STEP).should('be.visible');
completeTour();
});
it('can be skipped', () => {
cy.get(WELCOME_STEP).should('be.visible');
skipTour();
// step 1 is not displayed
cy.get(WELCOME_STEP).should('not.exist');
// step 2 is not displayed
cy.get(MANAGE_STEP).should('not.exist');
});
});
});

View file

@ -34,6 +34,10 @@ module.exports = (on) => {
loader: 'ts-loader',
options: { transpileOnly: true },
},
{
test: /\.gif$/,
loader: 'file-loader',
},
],
},
},

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export const WELCOME_STEP = '[data-test-subj="welcomeStep"]';
export const MANAGE_STEP = '[data-test-subj="manageStep"]';
export const ALERTS_STEP = '[data-test-subj="alertsStep"]';
export const CASES_STEP = '[data-test-subj="casesStep"]';
export const DATA_STEP = '[data-test-subj="dataStep"]';
export const NEXT_STEP_BUTTON = '[data-test-subj="onboarding--securityTourNextStepButton"]';
export const END_TOUR_BUTTON = '[data-test-subj="onboarding--securityTourEndButton"]';
export const SKIP_TOUR_BUTTON = '[data-test-subj="onboarding--securityTourSkipButton"]';

View file

@ -0,0 +1,25 @@
/*
* 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 {
NEXT_STEP_BUTTON,
END_TOUR_BUTTON,
DATA_STEP,
SKIP_TOUR_BUTTON,
} from '../screens/guided_onboarding';
export const goToNextStep = (currentStep: string) => {
cy.get(`${currentStep} ${NEXT_STEP_BUTTON}`).click();
};
export const completeTour = () => {
cy.get(`${DATA_STEP} ${END_TOUR_BUTTON}`).click();
};
export const skipTour = () => {
cy.get(SKIP_TOUR_BUTTON).click();
};

View file

@ -10,7 +10,7 @@ import {
EuiHeaderSection,
EuiHeaderSectionItem,
} from '@elastic/eui';
import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { createPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { i18n } from '@kbn/i18n';
@ -27,6 +27,7 @@ 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',
@ -69,6 +70,12 @@ export const GlobalHeader = React.memo(
};
}, [portalNode, setHeaderActionMenu, theme.theme$]);
const { isTourShown, endTour } = useTourContext();
const closeOnboardingTourIfShown = useCallback(() => {
if (isTourShown) {
endTour();
}
}, [isTourShown, endTour]);
return (
<InPortal node={portalNode}>
<EuiHeaderSection side="right">
@ -85,6 +92,7 @@ export const GlobalHeader = React.memo(
data-test-subj="add-data"
href={href}
iconType="indexOpen"
onClick={closeOnboardingTourIfShown}
>
{BUTTON_ADD_DATA}
</EuiHeaderLink>

View file

@ -55,15 +55,17 @@ const HomePageComponent: React.FC<HomePageProps> = ({
return (
<SecuritySolutionAppWrapper className="kbnAppWrapper">
<ConsoleManager>
<GlobalHeader setHeaderActionMenu={setHeaderActionMenu} />
<DragDropContextWrapper browserFields={browserFields}>
<TourContextProvider>
<SecuritySolutionTemplateWrapper onAppLeave={onAppLeave}>
{children}
</SecuritySolutionTemplateWrapper>
</TourContextProvider>
</DragDropContextWrapper>
<HelpMenu />
<TourContextProvider>
<>
<GlobalHeader setHeaderActionMenu={setHeaderActionMenu} />
<DragDropContextWrapper browserFields={browserFields}>
<SecuritySolutionTemplateWrapper onAppLeave={onAppLeave}>
{children}
</SecuritySolutionTemplateWrapper>
</DragDropContextWrapper>
<HelpMenu />
</>
</TourContextProvider>
</ConsoleManager>
</SecuritySolutionAppWrapper>
);

View file

@ -5,4 +5,9 @@
* 2.0.
*/
export { useTourContext, TourContextProvider } from './tour';
export {
useTourContext,
TourContextProvider,
SECURITY_TOUR_ACTIVE_KEY,
SECURITY_TOUR_STEP_KEY,
} from './tour';

View file

@ -0,0 +1,71 @@
/*
* 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);
});
});
});

View file

@ -28,13 +28,14 @@ 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 => {
return Boolean(localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY));
const localStorageValue = localStorage.getItem(SECURITY_TOUR_ACTIVE_KEY);
return localStorageValue ? JSON.parse(localStorageValue) : false;
};
const saveIsTourActiveToLocalStorage = (isTourActive: boolean): void => {
export const saveIsTourActiveToLocalStorage = (isTourActive: boolean): void => {
localStorage.setItem(SECURITY_TOUR_ACTIVE_KEY, JSON.stringify(isTourActive));
};
const getTourStepFromLocalStorage = (): number => {
export const getTourStepFromLocalStorage = (): number => {
return Number(localStorage.getItem(SECURITY_TOUR_STEP_KEY) ?? 1);
};
const saveTourStepToLocalStorage = (step: number): void => {
@ -96,7 +97,7 @@ const getSteps = (tourControls: {
</EuiButtonEmpty>
);
return tourConfig.map((stepConfig: StepConfig) => {
const { content, imageConfig, ...rest } = stepConfig;
const { content, imageConfig, dataTestSubj, ...rest } = stepConfig;
return (
<EuiTourStep
{...rest}
@ -107,6 +108,9 @@ const getSteps = (tourControls: {
stepsTotal={tourConfig.length}
isStepOpen={stepConfig.step === activeStep}
onFinish={() => resetTour()}
panelProps={{
'data-test-subj': dataTestSubj,
}}
content={
<>
<EuiText size="xs">
@ -128,10 +132,12 @@ const getSteps = (tourControls: {
export interface TourContextValue {
isTourShown: boolean;
endTour: () => void;
}
const TourContext = createContext<TourContextValue>({
isTourShown: false,
endTour: () => {},
} as TourContextValue);
export const TourContextProvider = ({ children }: { children: ReactChild }) => {
@ -163,7 +169,7 @@ export const TourContextProvider = ({ children }: { children: ReactChild }) => {
const isSmallScreen = useIsWithinBreakpoints(['xs', 's']);
const showTour = isTourActive && !isSmallScreen;
const context: TourContextValue = { isTourShown: showTour };
const context: TourContextValue = { isTourShown: showTour, endTour: resetTour };
return (
<TourContext.Provider value={context}>
<>

View file

@ -10,11 +10,9 @@ 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<
EuiTourStepProps,
'step' | 'content' | 'anchorPosition' | 'title' | 'data-test-subj'
> & {
export type StepConfig = Pick<EuiTourStepProps, 'step' | 'content' | 'anchorPosition' | 'title'> & {
anchor: string;
dataTestSubj: string;
imageConfig?: {
altText: string;
src: string;
@ -33,38 +31,39 @@ export const tourConfig: TourConfig = [
'xpack.securitySolution.guided_onboarding.tour.overviewStep.tourContent',
{
defaultMessage:
'Take a quick tour of the Security solution to get a feel for how it works.',
'Take a quick tour to explore a unified workflow for investigating suspicious activity.',
}
),
anchor: `[id^="SolutionNav"]`,
anchorPosition: 'rightUp',
'data-test-subj': 'welcomeStep',
dataTestSubj: 'welcomeStep',
},
{
step: 2,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.manageStep.tourTitle', {
defaultMessage: 'Define prevention, detection, and response across your entire ecosystem',
defaultMessage: 'Protect your ecosystem',
}),
content: i18n.translate(
'xpack.securitySolution.guided_onboarding.tour.manageStep.tourContent',
{
defaultMessage:
'Create rules to detect and prevent malicious activity, and implement threat intelligence to protect endpoints and cloud workloads.',
'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',
'data-test-subj': 'manageStep',
dataTestSubj: 'manageStep',
},
{
step: 3,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.alertsStep.tourTitle', {
defaultMessage: 'Get notified when your security rules are triggered',
defaultMessage: 'Get notified when something changes',
}),
content: i18n.translate(
'xpack.securitySolution.guided_onboarding.tour.alertsStep.tourContent',
{
defaultMessage: 'Detect, investigate, and respond to evolving threats in your environment.',
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"]`,
@ -78,16 +77,16 @@ export const tourConfig: TourConfig = [
}
),
},
'data-test-subj': 'alertsStep',
dataTestSubj: 'alertsStep',
},
{
step: 4,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourTitle', {
defaultMessage: 'Collect and share information about security issues',
defaultMessage: 'Create a case to track your investigation',
}),
content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.casesStep.tourContent', {
defaultMessage:
'Track key investigation details, collect alerts in a central location, and more.',
'Collect evidence, add more collaborators, and even push case details to third-party case management systems.',
}),
anchor: `[data-test-subj="groupedNavItemLink-cases"]`,
anchorPosition: 'rightUp',
@ -100,18 +99,18 @@ export const tourConfig: TourConfig = [
}
),
},
'data-test-subj': 'casesStep',
dataTestSubj: 'casesStep',
},
{
step: 5,
title: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourTitle', {
defaultMessage: `You're ready!`,
defaultMessage: `Start gathering your data!`,
}),
content: i18n.translate('xpack.securitySolution.guided_onboarding.tour.dataStep.tourContent', {
defaultMessage: `View and add your first integration to start protecting your environment. Return to the Security solution when you're done.`,
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',
'data-test-subj': 'dataStep',
dataTestSubj: 'dataStep',
},
];