[Guided onboarding] Added Guided onboarding to the Fleet plugin (#142185)

* [Guided onboarding] Added a EuiTour for guided onboarding in Integrations

* [Guided onboarding] Added data step completion for Elastic Defend

* [Guided onboarding] Added tests for api

* [Guided onboarding] Fixed jest tests

* [Guided onboarding] Added a fail safe for guided onboarding plugin not available in fleet

* [Guided onboarding] Moved the guided onboarding hook to a different folder in the fleet plugin, also fixed tests

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* [Guided onboarding] Updates after the merge conflicts

* [Guided onboarding] Fixed typos

* [Guided onboarding] Fixed types error

* Update x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/with_guided_onboarding_tour.tsx

Co-authored-by: Kelly Murphy <kelly.murphy@elastic.co>

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Kelly Murphy <kelly.murphy@elastic.co>
This commit is contained in:
Yulia Čech 2022-10-05 14:20:04 +02:00 committed by GitHub
parent 0a03a527e9
commit 27ac4fc26e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 532 additions and 79 deletions

View file

@ -21,6 +21,11 @@ export const securityConfig: GuideConfig = {
'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.',
'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.',
],
integration: 'endpoint',
location: {
appID: 'integrations',
path: '/browse/security',
},
},
{
id: 'rules',

View file

@ -0,0 +1,31 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { GuidedOnboardingPluginStart } from '.';
const apiServiceMock: jest.Mocked<GuidedOnboardingPluginStart> = {
guidedOnboardingApi: {
setup: jest.fn(),
fetchActiveGuideState$: () => new BehaviorSubject(undefined),
fetchAllGuidesState: jest.fn(),
updateGuideState: jest.fn(),
activateGuide: jest.fn(),
completeGuide: jest.fn(),
isGuideStepActive$: () => new BehaviorSubject(false),
startGuideStep: jest.fn(),
completeGuideStep: jest.fn(),
isGuidedOnboardingActiveForIntegration$: () => new BehaviorSubject(false),
completeGuidedOnboardingForIntegration: jest.fn(),
},
};
export const guidedOnboardingMock = {
createSetup: () => {},
createStart: () => apiServiceMock,
};

View file

@ -0,0 +1,97 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { GuideState } from '../../common/types';
export const searchAddDataActiveState: GuideState = {
guideId: 'search',
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'active',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
};
export const searchAddDataInProgressState: GuideState = {
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'in_progress',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
guideId: 'search',
};
export const securityAddDataInProgressState: GuideState = {
guideId: 'security',
status: 'in_progress',
isActive: true,
steps: [
{
id: 'add_data',
status: 'in_progress',
},
{
id: 'rules',
status: 'inactive',
},
],
};
export const securityRulesActivesState: GuideState = {
guideId: 'security',
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'complete',
},
{
id: 'rules',
status: 'active',
},
],
};
export const noGuideActiveState: GuideState = {
guideId: 'security',
status: 'in_progress',
isActive: false,
steps: [
{
id: 'add_data',
status: 'in_progress',
},
{
id: 'rules',
status: 'inactive',
},
],
};

View file

@ -14,29 +14,17 @@ import { API_BASE_PATH } from '../../common/constants';
import { guidesConfig } from '../constants/guides_config';
import type { GuideState } from '../../common/types';
import { ApiService } from './api';
import {
noGuideActiveState,
searchAddDataActiveState,
securityAddDataInProgressState,
securityRulesActivesState,
} from './api.mocks';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
const mockActiveSearchGuideState: GuideState = {
guideId: searchGuide,
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'active',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
};
const endpointIntegration = 'endpoint';
const kubernetesIntegration = 'kubernetes';
describe('GuidedOnboarding ApiService', () => {
let httpClient: jest.Mocked<HttpSetup>;
@ -46,7 +34,7 @@ describe('GuidedOnboarding ApiService', () => {
beforeEach(() => {
httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' });
httpClient.get.mockResolvedValue({
state: { activeGuide: searchGuide, activeStep: firstStep },
state: [securityAddDataInProgressState],
});
apiService = new ApiService();
apiService.setup(httpClient);
@ -72,7 +60,7 @@ describe('GuidedOnboarding ApiService', () => {
await apiService.activateGuide(searchGuide);
const state = await firstValueFrom(apiService.fetchActiveGuideState$());
expect(state).toEqual(mockActiveSearchGuideState);
expect(state).toEqual(searchAddDataActiveState);
});
});
@ -87,14 +75,14 @@ describe('GuidedOnboarding ApiService', () => {
describe('updateGuideState', () => {
it('sends a request to the put API', async () => {
const updatedState: GuideState = {
...mockActiveSearchGuideState,
...searchAddDataActiveState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
id: searchAddDataActiveState.steps[0].id,
status: 'in_progress', // update the first step status
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
searchAddDataActiveState.steps[1],
searchAddDataActiveState.steps[2],
],
};
await apiService.updateGuideState(updatedState, false);
@ -108,14 +96,14 @@ describe('GuidedOnboarding ApiService', () => {
describe('isGuideStepActive$', () => {
it('returns true if the step has been started', async (done) => {
const updatedState: GuideState = {
...mockActiveSearchGuideState,
...searchAddDataActiveState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
id: searchAddDataActiveState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
searchAddDataActiveState.steps[1],
searchAddDataActiveState.steps[2],
],
};
await apiService.updateGuideState(updatedState, false);
@ -130,7 +118,7 @@ describe('GuidedOnboarding ApiService', () => {
});
it('returns false if the step is not been started', async (done) => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
await apiService.updateGuideState(searchAddDataActiveState, false);
subscription = apiService
.isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
@ -170,21 +158,18 @@ describe('GuidedOnboarding ApiService', () => {
});
it('reactivates a guide that has already been started', async () => {
await apiService.activateGuide(searchGuide, mockActiveSearchGuideState);
await apiService.activateGuide(searchGuide, searchAddDataActiveState);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...mockActiveSearchGuideState,
isActive: true,
}),
body: JSON.stringify(searchAddDataActiveState),
});
});
});
describe('completeGuide', () => {
const readyToCompleteGuideState: GuideState = {
...mockActiveSearchGuideState,
...searchAddDataActiveState,
steps: [
{
id: 'add_data',
@ -224,7 +209,7 @@ describe('GuidedOnboarding ApiService', () => {
it('returns undefined if the selected guide has uncompleted steps', async () => {
const incompleteGuideState: GuideState = {
...mockActiveSearchGuideState,
...searchAddDataActiveState,
steps: [
{
id: 'add_data',
@ -249,7 +234,7 @@ describe('GuidedOnboarding ApiService', () => {
describe('startGuideStep', () => {
beforeEach(async () => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
await apiService.updateGuideState(searchAddDataActiveState, false);
});
it('updates the selected step and marks it as in_progress', async () => {
@ -257,16 +242,16 @@ describe('GuidedOnboarding ApiService', () => {
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...mockActiveSearchGuideState,
...searchAddDataActiveState,
isActive: true,
status: 'in_progress',
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
id: searchAddDataActiveState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
searchAddDataActiveState.steps[1],
searchAddDataActiveState.steps[2],
],
}),
});
@ -281,14 +266,14 @@ describe('GuidedOnboarding ApiService', () => {
describe('completeGuideStep', () => {
it(`completes the step when it's in progress`, async () => {
const updatedState: GuideState = {
...mockActiveSearchGuideState,
...searchAddDataActiveState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
id: searchAddDataActiveState.steps[0].id,
status: 'in_progress', // Mark a step as in_progress in order to test the "completeGuideStep" behavior
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
searchAddDataActiveState.steps[1],
searchAddDataActiveState.steps[2],
],
};
await apiService.updateGuideState(updatedState, false);
@ -303,14 +288,14 @@ describe('GuidedOnboarding ApiService', () => {
...updatedState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
id: searchAddDataActiveState.steps[0].id,
status: 'complete',
},
{
id: mockActiveSearchGuideState.steps[1].id,
id: searchAddDataActiveState.steps[1].id,
status: 'active',
},
mockActiveSearchGuideState.steps[2],
searchAddDataActiveState.steps[2],
],
}),
});
@ -322,11 +307,91 @@ describe('GuidedOnboarding ApiService', () => {
});
it('does nothing if the step is not in progress', async () => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
await apiService.updateGuideState(searchAddDataActiveState, false);
await apiService.completeGuideStep(searchGuide, firstStep);
// Expect only 1 call from updateGuideState()
expect(httpClient.put).toHaveBeenCalledTimes(1);
});
});
describe('isGuidedOnboardingActiveForIntegration$', () => {
it('returns true if the integration is part of the active step', async (done) => {
httpClient.get.mockResolvedValue({
state: [securityAddDataInProgressState],
});
apiService.setup(httpClient);
subscription = apiService
.isGuidedOnboardingActiveForIntegration$(endpointIntegration)
.subscribe((isIntegrationInGuideStep) => {
if (isIntegrationInGuideStep) {
done();
}
});
});
it('returns false if another integration is part of the active step', async (done) => {
httpClient.get.mockResolvedValue({
state: [securityAddDataInProgressState],
});
apiService.setup(httpClient);
subscription = apiService
.isGuidedOnboardingActiveForIntegration$(kubernetesIntegration)
.subscribe((isIntegrationInGuideStep) => {
if (!isIntegrationInGuideStep) {
done();
}
});
});
it('returns false if no guide is active', async (done) => {
httpClient.get.mockResolvedValue({
state: [noGuideActiveState],
});
apiService.setup(httpClient);
subscription = apiService
.isGuidedOnboardingActiveForIntegration$(endpointIntegration)
.subscribe((isIntegrationInGuideStep) => {
if (!isIntegrationInGuideStep) {
done();
}
});
});
});
describe('completeGuidedOnboardingForIntegration', () => {
it(`completes the step if it's active for the integration`, async () => {
httpClient.get.mockResolvedValue({
state: [securityAddDataInProgressState],
});
apiService.setup(httpClient);
await apiService.completeGuidedOnboardingForIntegration(endpointIntegration);
expect(httpClient.put).toHaveBeenCalledTimes(1);
// this assertion depends on the guides config
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify(securityRulesActivesState),
});
});
it(`does nothing if the step has a different integration`, async () => {
httpClient.get.mockResolvedValue({
state: [securityAddDataInProgressState],
});
apiService.setup(httpClient);
await apiService.completeGuidedOnboardingForIntegration(kubernetesIntegration);
expect(httpClient.put).not.toHaveBeenCalled();
});
it(`does nothing if no guide is active`, async () => {
httpClient.get.mockResolvedValue({
state: [noGuideActiveState],
});
apiService.setup(httpClient);
await apiService.completeGuidedOnboardingForIntegration(endpointIntegration);
expect(httpClient.put).not.toHaveBeenCalled();
});
});
});

View file

@ -9,11 +9,17 @@
import { HttpSetup } from '@kbn/core/public';
import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs';
import { GuidedOnboardingApi } from '../types';
import {
getGuideConfig,
getInProgressStepId,
isIntegrationInGuideStep,
isLastStep,
} from './helpers';
import { API_BASE_PATH } from '../../common/constants';
import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types';
import { isLastStep, getGuideConfig } from './helpers';
export class ApiService {
export class ApiService implements GuidedOnboardingApi {
private client: HttpSetup | undefined;
private onboardingGuideState$!: BehaviorSubject<GuideState | undefined>;
public isGuidePanelOpen$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
@ -102,8 +108,8 @@ export class ApiService {
/**
* Activates a guide by guideId
* This is useful for the onboarding landing page, when a user selects a guide to start or continue
* @param {GuideId} guideID the id of the guide (one of search, observability, security)
* @param {GuideState} guideState (optional) the selected guide state, if it exists (i.e., if a user is continuing a guide)
* @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {GuideState} guide (optional) the selected guide state, if it exists (i.e., if a user is continuing a guide)
* @return {Promise} a promise with the updated guide state
*/
public async activateGuide(
@ -150,7 +156,7 @@ export class ApiService {
* Completes a guide
* Updates the overall guide status to 'complete', and marks it as inactive
* This is useful for the dropdown panel, when the user clicks the "Continue using Elastic" button after completing all steps
* @param {GuideId} guideID the id of the guide (one of search, observability, security)
* @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @return {Promise} a promise with the updated guide state
*/
public async completeGuide(guideId: GuideId): Promise<{ state: GuideState } | undefined> {
@ -300,6 +306,38 @@ export class ApiService {
return undefined;
}
/**
* An observable with the boolean value if the guided onboarding is currently active for the integration.
* Returns true, if the passed integration is used in the current guide's step.
* Returns false otherwise.
* @param {string} integration the integration (package name) to check for in the guided onboarding config
* @return {Observable} an observable with the boolean value
*/
public isGuidedOnboardingActiveForIntegration$(integration?: string): Observable<boolean> {
return this.fetchActiveGuideState$().pipe(
map((state) => {
return state ? isIntegrationInGuideStep(state, integration) : false;
})
);
}
public async completeGuidedOnboardingForIntegration(
integration?: string
): Promise<{ state: GuideState } | undefined> {
if (integration) {
const currentState = await firstValueFrom(this.fetchActiveGuideState$());
if (currentState) {
const inProgressStepId = getInProgressStepId(currentState);
if (inProgressStepId) {
const isIntegrationStepActive = isIntegrationInGuideStep(currentState, integration);
if (isIntegrationStepActive) {
return await this.completeGuideStep(currentState?.guideId, inProgressStepId);
}
}
}
}
}
}
export const apiService = new ApiService();

View file

@ -7,11 +7,16 @@
*/
import { guidesConfig } from '../constants/guides_config';
import { isLastStep } from './helpers';
import { isIntegrationInGuideStep, isLastStep } from './helpers';
import {
noGuideActiveState,
securityAddDataInProgressState,
securityRulesActivesState,
} from './api.mocks';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
const lastStep = guidesConfig[searchGuide].steps[2].id;
const lastStep = guidesConfig[searchGuide].steps[guidesConfig[searchGuide].steps.length - 1].id;
describe('GuidedOnboarding ApiService helpers', () => {
// this test suite depends on the guides config
@ -26,4 +31,27 @@ describe('GuidedOnboarding ApiService helpers', () => {
expect(result).toBe(false);
});
});
describe('isIntegrationInGuideStep', () => {
it('return true if the integration is defined in the guide step config', () => {
const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'endpoint');
expect(result).toBe(true);
});
it('returns false if a different integration is defined in the guide step', () => {
const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'kubernetes');
expect(result).toBe(false);
});
it('returns false if no integration is defined in the guide step', () => {
const result = isIntegrationInGuideStep(securityRulesActivesState, 'endpoint');
expect(result).toBe(false);
});
it('returns false if no guide is active', () => {
const result = isIntegrationInGuideStep(noGuideActiveState, 'endpoint');
expect(result).toBe(false);
});
it('returns false if no integration passed', () => {
const result = isIntegrationInGuideStep(securityAddDataInProgressState);
expect(result).toBe(false);
});
});
});

View file

@ -6,9 +6,9 @@
* Side Public License, v 1.
*/
import type { GuideId } from '../../common/types';
import type { GuideId, GuideState, GuideStepIds } from '../../common/types';
import { guidesConfig } from '../constants/guides_config';
import type { GuideConfig, StepConfig } from '../types';
import { GuideConfig, StepConfig } from '../types';
export const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) {
@ -33,3 +33,26 @@ export const isLastStep = (guideID: string, stepID: string): boolean => {
}
return false;
};
export const getInProgressStepId = (state: GuideState): GuideStepIds | undefined => {
const inProgressStep = state.steps.find((step) => step.status === 'in_progress');
return inProgressStep ? inProgressStep.id : undefined;
};
const getInProgressStepConfig = (state: GuideState): StepConfig | undefined => {
const inProgressStepId = getInProgressStepId(state);
if (inProgressStepId) {
const config = getGuideConfig(state.guideId);
if (config) {
return config.steps.find((step) => step.id === inProgressStepId);
}
}
};
export const isIntegrationInGuideStep = (state: GuideState, integration?: string): boolean => {
if (state.isActive) {
const stepConfig = getInProgressStepConfig(state);
return stepConfig ? stepConfig.integration === integration : false;
}
return false;
};

View file

@ -6,15 +6,16 @@
* Side Public License, v 1.
*/
import { Observable } from 'rxjs';
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { GuideId, GuideStepIds, StepStatus } from '../common/types';
import { ApiService } from './services/api';
import { HttpSetup } from '@kbn/core/public';
import { GuideId, GuideState, GuideStepIds, StepStatus } from '../common/types';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface GuidedOnboardingPluginSetup {}
export interface GuidedOnboardingPluginStart {
guidedOnboardingApi?: ApiService;
guidedOnboardingApi?: GuidedOnboardingApi;
}
export interface AppPluginStartDependencies {
@ -25,6 +26,34 @@ export interface ClientConfigType {
ui: boolean;
}
export interface GuidedOnboardingApi {
setup: (httpClient: HttpSetup) => void;
fetchActiveGuideState$: () => Observable<GuideState | undefined>;
fetchAllGuidesState: () => Promise<{ state: GuideState[] } | undefined>;
updateGuideState: (
newState: GuideState,
panelState: boolean
) => Promise<{ state: GuideState } | undefined>;
activateGuide: (
guideId: GuideId,
guide?: GuideState
) => Promise<{ state: GuideState } | undefined>;
completeGuide: (guideId: GuideId) => Promise<{ state: GuideState } | undefined>;
isGuideStepActive$: (guideId: GuideId, stepId: GuideStepIds) => Observable<boolean>;
startGuideStep: (
guideId: GuideId,
stepId: GuideStepIds
) => Promise<{ state: GuideState } | undefined>;
completeGuideStep: (
guideId: GuideId,
stepId: GuideStepIds
) => Promise<{ state: GuideState } | undefined>;
isGuidedOnboardingActiveForIntegration$: (integration?: string) => Observable<boolean>;
completeGuidedOnboardingForIntegration: (
integration?: string
) => Promise<{ state: GuideState } | undefined>;
}
export interface StepConfig {
id: GuideStepIds;
title: string;
@ -34,6 +63,7 @@ export interface StepConfig {
path: string;
};
status?: StepStatus;
integration?: string;
}
export interface GuideConfig {
title: string;

View file

@ -15,6 +15,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { CoreScopedHistory } from '@kbn/core/public';
import { getStorybookContextProvider } from '@kbn/custom-integrations-plugin/storybook';
import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks';
import { IntegrationsAppContext } from '../../public/applications/integrations/app';
import type { FleetConfigType, FleetStartServices } from '../../public/plugin';
@ -110,6 +111,7 @@ export const StorybookContext: React.FC<{ storyContext?: Parameters<DecoratorFn>
writeIntegrationPolicies: true,
},
},
guidedOnboarding: guidedOnboardingMock.createStart(),
}),
[isCloudEnabled]
);

View file

@ -8,7 +8,7 @@
"server": true,
"ui": true,
"configPath": ["xpack", "fleet"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security", "unifiedSearch", "savedObjectsTagging", "taskManager"],
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security", "unifiedSearch", "savedObjectsTagging", "taskManager", "guidedOnboarding"],
"optionalPlugins": ["features", "cloud", "usageCollection", "home", "globalSearch", "telemetry", "discover", "ingestPipelines"],
"extraPublicDirs": ["common"],
"requiredBundles": ["kibanaReact", "cloudChat", "esUiShared", "infra", "kibanaUtils", "usageCollection", "unifiedSearch"]

View file

@ -27,6 +27,8 @@ import type { SearchHit } from '@kbn/es-types';
import styled from 'styled-components';
import { useStartServices, useIsGuidedOnboardingActive } from '../../../../../../../hooks';
import type { PackageInfo } from '../../../../../../../../common';
import {
@ -136,8 +138,15 @@ export const ConfirmIncomingDataWithPreview: React.FunctionComponent<Props> = ({
);
const { enrolledAgents, numAgentsWithData } = useGetAgentIncomingData(incomingData, packageInfo);
const isGuidedOnboardingActive = useIsGuidedOnboardingActive(packageInfo?.name);
const { guidedOnboarding } = useStartServices();
if (!isLoading && enrolledAgents > 0 && numAgentsWithData > 0) {
setAgentDataConfirmed(true);
if (isGuidedOnboardingActive) {
guidedOnboarding.guidedOnboardingApi?.completeGuidedOnboardingForIntegration(
packageInfo?.name
);
}
}
if (!agentDataConfirmed) {
return (

View file

@ -0,0 +1,64 @@
/*
* 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, { useEffect, useState } from 'react';
import type { FunctionComponent, ReactElement } from 'react';
import { EuiButton, EuiText, EuiTourStep } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
type TourType = 'addIntegrationButton' | 'integrationsList';
const getTourConfig = (packageKey: string, tourType: TourType) => {
if (packageKey.startsWith('endpoint') && tourType === 'addIntegrationButton') {
return {
title: i18n.translate('xpack.fleet.guidedOnboardingTour.endpointButton.title', {
defaultMessage: 'Add Elastic Defend',
}),
description: i18n.translate('xpack.fleet.guidedOnboardingTour.endpointButton.description', {
defaultMessage:
'In just a few steps, configure your data with our recommended defaults. You can change this later.',
}),
};
}
return null;
};
export const WithGuidedOnboardingTour: FunctionComponent<{
packageKey: string;
isGuidedOnboardingActive: boolean;
tourType: TourType;
children: ReactElement;
}> = ({ packageKey, isGuidedOnboardingActive, tourType, children }) => {
const [isGuidedOnboardingTourOpen, setIsGuidedOnboardingTourOpen] =
useState<boolean>(isGuidedOnboardingActive);
useEffect(() => {
setIsGuidedOnboardingTourOpen(isGuidedOnboardingActive);
}, [isGuidedOnboardingActive]);
const config = getTourConfig(packageKey, tourType);
return config ? (
<EuiTourStep
content={<EuiText>{config.description}</EuiText>}
isStepOpen={isGuidedOnboardingTourOpen}
maxWidth={350}
onFinish={() => setIsGuidedOnboardingTourOpen(false)}
step={1}
stepsTotal={1}
title={config.title}
anchorPosition="rightUp"
footerAction={
<EuiButton onClick={() => setIsGuidedOnboardingTourOpen(false)} size="s" color="success">
{i18n.translate('xpack.fleet.guidedOnboardingTour.nextButtonLabel', {
defaultMessage: 'Next',
})}
</EuiButton>
}
>
{children}
</EuiTourStep>
) : (
<>{children}</>
);
};

View file

@ -39,7 +39,12 @@ import {
} from '../../../../hooks';
import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants';
import { ExperimentalFeaturesService } from '../../../../services';
import { useGetPackageInfoByKey, useLink, useAgentPolicyContext } from '../../../../hooks';
import {
useGetPackageInfoByKey,
useLink,
useAgentPolicyContext,
useIsGuidedOnboardingActive,
} from '../../../../hooks';
import { pkgKeyFromPackageInfo } from '../../../../services';
import type { DetailViewPanelName, PackageInfo } from '../../../../types';
import { InstallStatus } from '../../../../types';
@ -47,6 +52,8 @@ import { Error, Loading, HeaderReleaseBadge } from '../../../../components';
import type { WithHeaderLayoutProps } from '../../../../layouts';
import { WithHeaderLayout } from '../../../../layouts';
import { WithGuidedOnboardingTour } from './components/with_guided_onboarding_tour';
import { useIsFirstTimeAgentUser } from './hooks';
import { getInstallPkgRouteOptions } from './utils';
import {
@ -154,6 +161,7 @@ export function Detail() {
const { isFirstTimeAgentUser = false, isLoading: firstTimeUserLoading } =
useIsFirstTimeAgentUser();
const isGuidedOnboardingActive = useIsGuidedOnboardingActive(pkgName);
// Refresh package info when status change
const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus);
@ -292,6 +300,7 @@ export function Detail() {
isCloud,
isExperimentalAddIntegrationPageEnabled,
isFirstTimeAgentUser,
isGuidedOnboardingActive,
pkgkey,
});
@ -305,6 +314,7 @@ export function Detail() {
isCloud,
isExperimentalAddIntegrationPageEnabled,
isFirstTimeAgentUser,
isGuidedOnboardingActive,
pathname,
pkgkey,
search,
@ -349,19 +359,25 @@ export function Detail() {
{ isDivider: true },
{
content: (
<AddIntegrationButton
userCanInstallPackages={userCanInstallPackages}
href={getHref('add_integration_to_policy', {
pkgkey,
...(integration ? { integration } : {}),
...(agentPolicyIdFromContext
? { agentPolicyId: agentPolicyIdFromContext }
: {}),
})}
missingSecurityConfiguration={missingSecurityConfiguration}
packageName={integrationInfo?.title || packageInfo.title}
onClick={handleAddIntegrationPolicyClick}
/>
<WithGuidedOnboardingTour
packageKey={pkgkey}
tourType={'addIntegrationButton'}
isGuidedOnboardingActive={isGuidedOnboardingActive}
>
<AddIntegrationButton
userCanInstallPackages={userCanInstallPackages}
href={getHref('add_integration_to_policy', {
pkgkey,
...(integration ? { integration } : {}),
...(agentPolicyIdFromContext
? { agentPolicyId: agentPolicyIdFromContext }
: {}),
})}
missingSecurityConfiguration={missingSecurityConfiguration}
packageName={integrationInfo?.title || packageInfo.title}
onClick={handleAddIntegrationPolicyClick}
/>
</WithGuidedOnboardingTour>
),
},
].map((item, index) => (
@ -385,6 +401,7 @@ export function Detail() {
packageInfo,
updateAvailable,
isInstalled,
isGuidedOnboardingActive,
userCanInstallPackages,
getHref,
pkgkey,

View file

@ -22,6 +22,7 @@ describe('getInstallPkgRouteOptions', () => {
integration: 'myintegration',
pkgkey: 'myintegration-1.0.0',
isFirstTimeAgentUser: false,
isGuidedOnboardingActive: false,
isCloud: false,
isExperimentalAddIntegrationPageEnabled: false,
};
@ -51,6 +52,7 @@ describe('getInstallPkgRouteOptions', () => {
pkgkey: 'myintegration-1.0.0',
agentPolicyId: '12345',
isFirstTimeAgentUser: false,
isGuidedOnboardingActive: false,
isCloud: false,
isExperimentalAddIntegrationPageEnabled: false,
};
@ -78,6 +80,7 @@ describe('getInstallPkgRouteOptions', () => {
integration: 'myintegration',
pkgkey: 'myintegration-1.0.0',
isFirstTimeAgentUser: true,
isGuidedOnboardingActive: false,
isCloud: true,
isExperimentalAddIntegrationPageEnabled: true,
};
@ -105,6 +108,7 @@ describe('getInstallPkgRouteOptions', () => {
integration: 'myintegration',
pkgkey: 'apm-1.0.0',
isFirstTimeAgentUser: true,
isGuidedOnboardingActive: false,
isCloud: true,
isExperimentalAddIntegrationPageEnabled: true,
};
@ -137,6 +141,7 @@ describe('getInstallPkgRouteOptions', () => {
integration: 'myintegration',
pkgkey: 'endpoint-1.0.0',
isFirstTimeAgentUser: true,
isGuidedOnboardingActive: false,
isCloud: true,
isExperimentalAddIntegrationPageEnabled: true,
};

View file

@ -29,6 +29,7 @@ interface GetInstallPkgRouteOptionsParams {
isCloud: boolean;
isExperimentalAddIntegrationPageEnabled: boolean;
isFirstTimeAgentUser: boolean;
isGuidedOnboardingActive: boolean;
}
const isPackageExemptFromStepsLayout = (pkgkey: string) =>
@ -45,13 +46,14 @@ export const getInstallPkgRouteOptions = ({
isFirstTimeAgentUser,
isCloud,
isExperimentalAddIntegrationPageEnabled,
isGuidedOnboardingActive,
}: GetInstallPkgRouteOptionsParams): [string, { path: string; state: unknown }] => {
const integrationOpts: { integration?: string } = integration ? { integration } : {};
const packageExemptFromStepsLayout = isPackageExemptFromStepsLayout(pkgkey);
const useMultiPageLayout =
isExperimentalAddIntegrationPageEnabled &&
isCloud &&
isFirstTimeAgentUser &&
(isFirstTimeAgentUser || isGuidedOnboardingActive) &&
!packageExemptFromStepsLayout;
const path = pagePathGetters.add_integration_to_policy({
pkgkey,

View file

@ -28,3 +28,4 @@ export * from './use_agent_policy_refresh';
export * from './use_package_installations';
export * from './use_agent_enrollment_flyout_data';
export * from './use_flyout_context';
export * from './use_is_guided_onboarding_active';

View file

@ -0,0 +1,29 @@
/*
* 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';
import useObservable from 'react-use/lib/useObservable';
import { of } from 'rxjs';
import { useStartServices } from '.';
export const useIsGuidedOnboardingActive = (packageName?: string): boolean => {
const [result, setResult] = useState<boolean>(false);
const { guidedOnboarding } = useStartServices();
const isGuidedOnboardingActiveForIntegration = useObservable(
// if guided onboarding is not available, return false
guidedOnboarding.guidedOnboardingApi
? guidedOnboarding.guidedOnboardingApi.isGuidedOnboardingActiveForIntegration$(packageName)
: of(false)
);
useEffect(() => {
setResult(!!isGuidedOnboardingActiveForIntegration);
}, [isGuidedOnboardingActiveForIntegration]);
return result;
};

View file

@ -13,6 +13,8 @@ import { coreMock } from '@kbn/core/public/mocks';
import type { IStorage } from '@kbn/kibana-utils-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks';
import { setHttpClient } from '../hooks/use_request';
import type { FleetAuthz } from '../../common';
@ -90,6 +92,7 @@ export const createStartServices = (basePath: string = '/mock'): MockedFleetStar
},
storage: new Storage(createMockStore()) as jest.Mocked<Storage>,
authz: fleetAuthzMock,
guidedOnboarding: guidedOnboardingMock.createStart(),
};
configureStartServices(startServices);

View file

@ -44,6 +44,7 @@ import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public';
import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, setupRouteService, appRoutesService } from '../common';
import { calculateAuthz, calculatePackagePrivilegesFromCapabilities } from '../common/authz';
@ -101,6 +102,7 @@ export interface FleetStartDeps {
share: SharePluginStart;
cloud?: CloudStart;
usageCollection?: UsageCollectionStart;
guidedOnboarding: GuidedOnboardingPluginStart;
}
export interface FleetStartServices extends CoreStart, Exclude<FleetStartDeps, 'cloud'> {
@ -110,6 +112,7 @@ export interface FleetStartServices extends CoreStart, Exclude<FleetStartDeps, '
discover?: DiscoverStart;
spaces?: SpacesPluginStart;
authz: FleetAuthz;
guidedOnboarding: GuidedOnboardingPluginStart;
}
export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDeps, FleetStartDeps> {

View file

@ -27,6 +27,7 @@
{ "path": "../licensing/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../encrypted_saved_objects/tsconfig.json" },
{"path": "../../../src/plugins/guided_onboarding/tsconfig.json"},
// optionalPlugins from ./kibana.json
{ "path": "../security/tsconfig.json" },