[Guided onboarding] State management improvements (#141278)

This commit is contained in:
Alison Goryachev 2022-10-03 15:46:07 -04:00 committed by GitHub
parent 8ad95df6fa
commit 059fecd311
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1225 additions and 383 deletions

View file

@ -23,6 +23,7 @@ import { CoreStart, ScopedHistory } from '@kbn/core/public';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types';
import { StepTwo } from './step_two';
import { StepOne } from './step_one';
import { StepThree } from './step_three';
import { Main } from './main';
interface GuidedOnboardingExampleAppDeps {
@ -60,6 +61,9 @@ export const GuidedOnboardingExampleApp = (props: GuidedOnboardingExampleAppDeps
<Route exact path="/stepTwo">
<StepTwo guidedOnboarding={guidedOnboarding} />
</Route>
<Route exact path="/stepThree">
<StepThree guidedOnboarding={guidedOnboarding} />
</Route>
</Switch>
</Router>
</EuiPageContent>

View file

@ -25,45 +25,50 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
import type {
GuidedOnboardingPluginStart,
GuidedOnboardingState,
UseCase,
GuideState,
GuideStepIds,
GuideId,
GuideStep,
} from '@kbn/guided-onboarding-plugin/public';
import { guidesConfig } from '@kbn/guided-onboarding-plugin/public';
interface MainProps {
guidedOnboarding: GuidedOnboardingPluginStart;
notifications: CoreStart['notifications'];
}
export const Main = (props: MainProps) => {
const {
guidedOnboarding: { guidedOnboardingApi },
notifications,
} = props;
const history = useHistory();
const [guideState, setGuideState] = useState<GuidedOnboardingState | undefined>(undefined);
const [guidesState, setGuidesState] = useState<GuideState[] | undefined>(undefined);
const [activeGuide, setActiveGuide] = useState<GuideState | undefined>(undefined);
const [selectedGuide, setSelectedGuide] = useState<
GuidedOnboardingState['activeGuide'] | undefined
>(undefined);
const [selectedStep, setSelectedStep] = useState<GuidedOnboardingState['activeStep'] | undefined>(
undefined
);
const [selectedGuide, setSelectedGuide] = useState<GuideId | undefined>(undefined);
const [selectedStep, setSelectedStep] = useState<GuideStepIds | undefined>(undefined);
useEffect(() => {
const subscription = guidedOnboardingApi
?.fetchGuideState$()
.subscribe((newState: GuidedOnboardingState) => {
setGuideState(newState);
});
return () => subscription?.unsubscribe();
const fetchGuidesState = async () => {
const newGuidesState = await guidedOnboardingApi?.fetchAllGuidesState();
setGuidesState(newGuidesState ? newGuidesState.state : []);
};
fetchGuidesState();
}, [guidedOnboardingApi]);
const startGuide = async (guide: UseCase) => {
const response = await guidedOnboardingApi?.updateGuideState({
activeGuide: guide,
activeStep: 'add_data',
});
useEffect(() => {
const newActiveGuide = guidesState?.find((guide) => guide.isActive === true);
if (newActiveGuide) {
setActiveGuide(newActiveGuide);
}
}, [guidesState, setActiveGuide]);
const activateGuide = async (guideId: GuideId, guideState?: GuideState) => {
const response = await guidedOnboardingApi?.activateGuide(guideId, guideState);
if (response) {
notifications.toasts.addSuccess(
@ -75,11 +80,45 @@ export const Main = (props: MainProps) => {
};
const updateGuideState = async () => {
const response = await guidedOnboardingApi?.updateGuideState({
activeGuide: selectedGuide!,
activeStep: selectedStep!,
const selectedGuideConfig = guidesConfig[selectedGuide!];
const selectedStepIndex = selectedGuideConfig.steps.findIndex(
(step) => step.id === selectedStep!
);
// Noop if the selected step is invalid
if (selectedStepIndex === -1) {
return;
}
const updatedSteps: GuideStep[] = selectedGuideConfig.steps.map((step, stepIndex) => {
if (selectedStepIndex > stepIndex) {
return {
id: step.id,
status: 'complete',
};
}
if (selectedStepIndex < stepIndex) {
return {
id: step.id,
status: 'inactive',
};
}
return {
id: step.id,
status: 'active',
};
});
const updatedGuideState: GuideState = {
isActive: true,
status: 'in_progress',
steps: updatedSteps,
guideId: selectedGuide!,
};
const response = await guidedOnboardingApi?.updateGuideState(updatedGuideState, true);
if (response) {
notifications.toasts.addSuccess(
i18n.translate('guidedOnboardingExample.updateGuideState.toastLabel', {
@ -116,7 +155,7 @@ export const Main = (props: MainProps) => {
so there is no need to 'load' the state from the server."
/>
</p>
{guideState ? (
{activeGuide ? (
<dl>
<dt>
<FormattedMessage
@ -124,53 +163,86 @@ export const Main = (props: MainProps) => {
defaultMessage="Active guide"
/>
</dt>
<dd>{guideState.activeGuide ?? 'undefined'}</dd>
<dd>{activeGuide.guideId}</dd>
<dt>
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.state.activeStepLabel"
defaultMessage="Active step"
defaultMessage="Steps status"
/>
</dt>
<dd>{guideState.activeStep ?? 'undefined'}</dd>
<dd>
{activeGuide.steps.map((step) => {
return (
<>
{`Step "${step.id}": ${step.status}`} <br />
</>
);
})}
</dd>
</dl>
) : undefined}
) : (
<p>
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.state.noActiveGuidesMessage"
defaultMessage="There are currently no active guides."
/>
</p>
)}
</EuiText>
<EuiHorizontalRule />
<EuiText>
<h3>
<FormattedMessage
id="guidedOnboardingExample.main.startGuide.title"
defaultMessage="(Re-)Start a guide"
defaultMessage="Guides"
/>
</h3>
</EuiText>
<EuiSpacer />
<EuiFlexGroup>
{(Object.keys(guidesConfig) as GuideId[]).map((guideId) => {
const guideState = guidesState?.find((guide) => guide.guideId === guideId);
return (
<EuiFlexItem>
<EuiButton onClick={() => startGuide('search')} fill>
<EuiButton
onClick={() => activateGuide(guideId, guideState)}
fill
disabled={guideState?.status === 'complete'}
>
{guideState === undefined && (
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.search.buttonLabel"
defaultMessage="(Re-)Start search guide"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton onClick={() => startGuide('observability')} fill>
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.observability.buttonLabel"
defaultMessage="(Re-)Start observability guide"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton onClick={() => startGuide('security')} fill>
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.security.label"
defaultMessage="(Re-)Start security guide"
id="guidedOnboardingExample.guidesSelection.startButtonLabel"
defaultMessage="Start {guideId} guide"
values={{
guideId,
}}
/>
)}
{(guideState?.isActive === true ||
guideState?.status === 'in_progress' ||
guideState?.status === 'ready_to_complete') && (
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.continueButtonLabel"
defaultMessage="Continue {guideId} guide"
values={{
guideId,
}}
/>
)}
{guideState?.status === 'complete' && (
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.completeButtonLabel"
defaultMessage="Guide {guideId} complete"
values={{
guideId,
}}
/>
)}
</EuiButton>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<EuiSpacer />
<EuiHorizontalRule />
@ -187,16 +259,15 @@ export const Main = (props: MainProps) => {
<EuiFlexItem>
<EuiFormRow label="Guide" helpText="Select a guide">
<EuiSelect
id={'guideSelect'}
id="guideSelect"
options={[
{ value: 'observability', text: 'observability' },
{ value: 'security', text: 'security' },
{ value: 'search', text: 'search' },
{ value: '', text: 'unset' },
]}
value={selectedGuide}
onChange={(e) => {
const value = e.target.value as UseCase;
const value = e.target.value as GuideId;
const shouldResetState = value.trim().length === 0;
if (shouldResetState) {
setSelectedGuide(undefined);
@ -209,10 +280,10 @@ export const Main = (props: MainProps) => {
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Step">
<EuiFormRow label="Step ID">
<EuiFieldText
value={selectedStep}
onChange={(e) => setSelectedStep(e.target.value)}
onChange={(e) => setSelectedStep(e.target.value as GuideStepIds)}
/>
</EuiFormRow>
</EuiFlexItem>

View file

@ -0,0 +1,90 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { EuiButton, EuiSpacer, EuiText, EuiTitle, EuiTourStep } from '@elastic/eui';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiPageContentHeader_Deprecated as EuiPageContentHeader,
EuiPageContentBody_Deprecated as EuiPageContentBody,
} from '@elastic/eui';
interface StepThreeProps {
guidedOnboarding: GuidedOnboardingPluginStart;
}
export const StepThree = (props: StepThreeProps) => {
const {
guidedOnboarding: { guidedOnboardingApi },
} = props;
const [isTourStepOpen, setIsTourStepOpen] = useState<boolean>(false);
useEffect(() => {
const subscription = guidedOnboardingApi
?.isGuideStepActive$('search', 'search_experience')
.subscribe((isStepActive) => {
setIsTourStepOpen(isStepActive);
});
return () => subscription?.unsubscribe();
}, [guidedOnboardingApi]);
return (
<>
<EuiPageContentHeader>
<EuiTitle>
<h2>
<FormattedMessage
id="guidedOnboardingExample.stepThree.title"
defaultMessage="Example step 3"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiText>
<p>
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.stepThree.explanation"
defaultMessage="The code on this page is listening to the guided setup state using an Observable subscription. If the state is set to
Search guide, step Search experience, a EUI tour will be displayed, pointing to the button below."
/>
</p>
</EuiText>
<EuiSpacer />
<EuiTourStep
content={
<EuiText>
<p>Click this button to complete step 3.</p>
</EuiText>
}
isStepOpen={isTourStepOpen}
minWidth={300}
onFinish={() => {
setIsTourStepOpen(false);
}}
step={1}
stepsTotal={1}
title="Step Build search experience"
anchorPosition="rightUp"
>
<EuiButton
onClick={async () => {
await guidedOnboardingApi?.completeGuideStep('search', 'search_experience');
}}
>
Complete step 3
</EuiButton>
</EuiTourStep>
</EuiPageContentBody>
</>
);
};

View file

@ -55,7 +55,7 @@ export const StepTwo = (props: StepTwoProps) => {
<FormattedMessage
id="guidedOnboardingExample.guidesSelection.stepTwo.explanation"
defaultMessage="The code on this page is listening to the guided setup state using an Observable subscription. If the state is set to
Search guide, step Search experience, a EUI tour will be displayed, pointing to the button below."
Search guide, step Browse documents, a EUI tour will be displayed, pointing to the button below."
/>
</p>
</EuiText>
@ -73,7 +73,7 @@ export const StepTwo = (props: StepTwoProps) => {
}}
step={1}
stepsTotal={1}
title="Step Search experience"
title="Step Browse documents"
anchorPosition="rightUp"
>
<EuiButton

View file

@ -71,6 +71,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",
@ -246,6 +251,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",
@ -425,6 +435,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",
@ -608,6 +623,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",
@ -833,6 +853,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",
@ -1019,6 +1044,11 @@ Object {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",

View file

@ -22,6 +22,8 @@ export const REMOVED_TYPES: string[] = [
'fleet-agents',
'fleet-agent-actions',
'fleet-enrollment-api-keys',
// replaced by guided-onboarding-guide-state in 8.6
'guided-setup-state',
// Was removed in 7.12
'ml-telemetry',
'server',

View file

@ -107,6 +107,11 @@ describe('createInitialState', () => {
"type": "fleet-enrollment-api-keys",
},
},
Object {
"term": Object {
"type": "guided-setup-state",
},
},
Object {
"term": Object {
"type": "ml-telemetry",

View file

@ -60,6 +60,7 @@ const previouslyRegisteredTypes = [
'fleet-preconfiguration-deletion-record',
'graph-workspace',
'guided-setup-state',
'guided-onboarding-guide-state',
'index-pattern',
'infrastructure-monitoring-log-view',
'infrastructure-ui-source',

View file

@ -0,0 +1,44 @@
/*
* 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.
*/
export type GuideId = 'observability' | 'security' | 'search';
export type ObservabilityStepIds = 'add_data' | 'view_dashboard' | 'tour_observability';
export type SecurityStepIds = 'add_data' | 'rules' | 'alerts' | 'cases';
export type SearchStepIds = 'add_data' | 'browse_docs' | 'search_experience';
export type GuideStepIds = ObservabilityStepIds | SecurityStepIds | SearchStepIds;
/**
* Allowed states for a guide:
* in_progress: Guide has been started
* ready_to_complete: All steps have been completed, but the "Continue using Elastic" button has not been clicked
* complete: All steps and the guide have been completed
*/
export type GuideStatus = 'in_progress' | 'ready_to_complete' | 'complete';
/**
* Allowed states for each step in a guide:
* inactive: Step has not started
* active: Step is ready to start (i.e., the guide has been started)
* in_progress: Step has been started and is in progress
* complete: Step has been completed
*/
export type StepStatus = 'inactive' | 'active' | 'in_progress' | 'complete';
export interface GuideStep {
id: GuideStepIds;
status: StepStatus;
}
export interface GuideState {
guideId: GuideId;
status: GuideStatus;
isActive?: boolean; // Drives the current guide shown in the dropdown panel
steps: GuideStep[];
}

View file

@ -13,18 +13,39 @@ import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { HttpSetup } from '@kbn/core/public';
import { apiService } from '../services/api';
import { guidesConfig } from '../constants/guides_config';
import type { GuideState } from '../../common/types';
import { apiService } from '../services/api';
import { GuidePanel } from './guide_panel';
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
const applicationMock = applicationServiceMock.createStartContract();
const mockActiveSearchGuideState: GuideState = {
guideId: 'search',
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'active',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
};
const getGuidePanel = () => () => {
return <GuidePanel application={applicationMock} api={apiService} />;
};
describe('GuidePanel', () => {
describe('Guided setup', () => {
let httpClient: jest.Mocked<HttpSetup>;
let testBed: TestBed;
@ -32,7 +53,7 @@ describe('GuidePanel', () => {
httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' });
// Set default state on initial request (no active guides)
httpClient.get.mockResolvedValue({
state: { activeGuide: 'unset', activeStep: 'unset' },
state: [],
});
apiService.setup(httpClient);
@ -48,29 +69,164 @@ describe('GuidePanel', () => {
jest.restoreAllMocks();
});
test('it should be disabled in there is no active guide', async () => {
describe('Button component', () => {
test('should be disabled in there is no active guide', async () => {
const { exists } = testBed;
expect(exists('disabledGuideButton')).toBe(true);
expect(exists('guideButton')).toBe(false);
expect(exists('guidePanel')).toBe(false);
});
test('it should be enabled if there is an active guide', async () => {
test('should be enabled if there is an active guide', async () => {
const { exists, component, find } = testBed;
await act(async () => {
// Enable the "search" guide
await apiService.updateGuideState({
activeGuide: 'search',
activeStep: guidesConfig.search.steps[0].id,
});
await apiService.updateGuideState(mockActiveSearchGuideState, true);
});
component.update();
expect(exists('disabledGuideButton')).toBe(false);
expect(exists('guideButton')).toBe(true);
expect(find('guideButton').text()).toEqual('Setup guide');
});
test('should show the step number in the button label if a step is active', async () => {
const { component, find } = testBed;
const mockInProgressSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
await act(async () => {
await apiService.updateGuideState(mockInProgressSearchGuideState, true);
});
component.update();
expect(find('guideButton').text()).toEqual('Setup guide: step 1');
});
});
describe('Panel component', () => {
test('should be enabled if a guide is activated', async () => {
const { exists, component, find } = testBed;
await act(async () => {
// Enable the "search" guide
await apiService.updateGuideState(mockActiveSearchGuideState, true);
});
component.update();
expect(exists('guidePanel')).toBe(true);
expect(exists('guideProgress')).toBe(false);
expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length);
});
test('should show the progress bar if the first step has been completed', async () => {
const { component, exists } = testBed;
const mockInProgressSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'complete',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
await act(async () => {
await apiService.updateGuideState(mockInProgressSearchGuideState, true);
});
component.update();
expect(exists('guidePanel')).toBe(true);
expect(exists('guideProgress')).toBe(true);
});
test('should show the "Continue using Elastic" button when all steps has been completed', async () => {
const { component, exists } = testBed;
const readyToCompleteGuideState: GuideState = {
guideId: 'search',
status: 'ready_to_complete',
isActive: true,
steps: [
{
id: 'add_data',
status: 'complete',
},
{
id: 'browse_docs',
status: 'complete',
},
{
id: 'search_experience',
status: 'complete',
},
],
};
await act(async () => {
await apiService.updateGuideState(readyToCompleteGuideState, true);
});
component.update();
expect(exists('useElasticButton')).toBe(true);
});
describe('Steps', () => {
test('should show "Start" button label if step has not been started', async () => {
const { component, find } = testBed;
await act(async () => {
// Enable the "search" guide
await apiService.updateGuideState(mockActiveSearchGuideState, true);
});
component.update();
expect(find('activeStepButtonLabel').text()).toEqual('Start');
});
test('should show "Continue" button label if step is in progress', async () => {
const { component, find } = testBed;
const mockInProgressSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
await act(async () => {
await apiService.updateGuideState(mockInProgressSearchGuideState, true);
});
component.update();
expect(find('activeStepButtonLabel').text()).toEqual('Continue');
});
});
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
@ -30,7 +30,9 @@ import { ApplicationStart } from '@kbn/core-application-browser';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { guidesConfig } from '../constants/guides_config';
import type { GuideConfig, StepStatus, GuidedOnboardingState, StepConfig } from '../types';
import type { GuideState, GuideStepIds } from '../../common/types';
import type { GuideConfig, StepConfig } from '../types';
import type { ApiService } from '../services/api';
import { GuideStep } from './guide_panel_step';
@ -41,47 +43,48 @@ interface GuidePanelProps {
application: ApplicationStart;
}
const getConfig = (state?: GuidedOnboardingState): GuideConfig | undefined => {
if (state?.activeGuide && state.activeGuide !== 'unset') {
return guidesConfig[state.activeGuide];
const getConfig = (state?: GuideState): GuideConfig | undefined => {
if (state) {
return guidesConfig[state.guideId];
}
return undefined;
};
const getCurrentStep = (
steps?: StepConfig[],
state?: GuidedOnboardingState
): number | undefined => {
if (steps && state?.activeStep) {
const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === state.activeStep);
if (activeStepIndex > -1) {
return activeStepIndex + 1;
const getStepNumber = (state?: GuideState): number | undefined => {
let stepNumber: number | undefined;
state?.steps.forEach((step, stepIndex) => {
// If the step is in_progress, show that step number
if (step.status === 'in_progress') {
stepNumber = stepIndex + 1;
}
return undefined;
// If the step is active, show the previous step number
if (step.status === 'active') {
stepNumber = stepIndex;
}
});
return stepNumber;
};
const getStepStatus = (steps: StepConfig[], stepIndex: number, activeStep?: string): StepStatus => {
const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === activeStep);
if (activeStepIndex < stepIndex) {
return 'incomplete';
const getProgress = (state?: GuideState): number => {
if (state) {
return state.steps.reduce((acc, currentVal) => {
if (currentVal.status === 'complete') {
acc = acc + 1;
}
if (activeStepIndex === stepIndex) {
return 'in_progress';
return acc;
}, 0);
}
return 'complete';
return 0;
};
export const GuidePanel = ({ api, application }: GuidePanelProps) => {
const { euiTheme } = useEuiTheme();
const [isGuideOpen, setIsGuideOpen] = useState(false);
const [guideState, setGuideState] = useState<GuidedOnboardingState | undefined>(undefined);
const isFirstRender = useRef(true);
const [guideState, setGuideState] = useState<GuideState | undefined>(undefined);
const styles = getGuidePanelStyles(euiTheme);
@ -89,10 +92,10 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen);
};
const navigateToStep = (step: StepConfig) => {
setIsGuideOpen(false);
if (step.location) {
application.navigateToApp(step.location.appID, { path: step.location.path });
const navigateToStep = async (stepId: GuideStepIds, stepLocation: StepConfig['location']) => {
await api.startGuideStep(guideState!.guideId, stepId);
if (stepLocation) {
application.navigateToApp(stepLocation.appID, { path: stepLocation.path });
}
};
@ -101,22 +104,25 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
application.navigateToApp('home', { path: '#getting_started' });
};
const completeGuide = async () => {
await api.completeGuide(guideState!.guideId);
};
useEffect(() => {
const subscription = api.fetchGuideState$().subscribe((newState) => {
if (
guideState?.activeGuide !== newState.activeGuide ||
guideState?.activeStep !== newState.activeStep
) {
if (isFirstRender.current) {
isFirstRender.current = false;
} else {
setIsGuideOpen(true);
const subscription = api.fetchActiveGuideState$().subscribe((newGuideState) => {
if (newGuideState) {
setGuideState(newGuideState);
}
}
setGuideState(newState);
});
return () => subscription.unsubscribe();
}, [api, guideState?.activeGuide, guideState?.activeStep]);
}, [api]);
useEffect(() => {
const subscription = api.isGuidePanelOpen$.subscribe((isGuidePanelOpen) => {
setIsGuideOpen(isGuidePanelOpen);
});
return () => subscription.unsubscribe();
}, [api]);
const guideConfig = getConfig(guideState);
@ -139,16 +145,17 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
);
}
const currentStep = getCurrentStep(guideConfig.steps, guideState);
const stepNumber = getStepNumber(guideState);
const stepsCompleted = getProgress(guideState);
return (
<>
<EuiButton onClick={toggleGuide} color="success" fill size="s" data-test-subj="guideButton">
{currentStep
{Boolean(stepNumber)
? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', {
defaultMessage: 'Setup guide: Step {currentStep}',
defaultMessage: 'Setup guide: step {stepNumber}',
values: {
currentStep,
stepNumber,
},
})
: i18n.translate('guidedOnboarding.guidedSetupButtonLabel', {
@ -203,21 +210,20 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
</>
)}
{/* Progress bar should only show after the first step has been complete */}
{stepsCompleted > 0 && (
<>
<EuiSpacer size="xl" />
{/*
TODO: Progress bar should only show after the first step has been started
We need to make changes to the state itself in order to support this
*/}
<EuiProgress
data-test-subj="guideProgress"
label={i18n.translate('guidedOnboarding.dropdownPanel.progressLabel', {
defaultMessage: 'Progress',
})}
value={currentStep ? currentStep - 1 : 0}
value={stepsCompleted}
valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', {
defaultMessage: '{stepCount} steps',
values: {
stepCount: `${currentStep ? currentStep - 1 : 0} / ${guideConfig.steps.length}`,
stepCount: `${stepsCompleted} / ${guideConfig.steps.length}`,
},
})}
max={guideConfig.steps.length}
@ -225,24 +231,40 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
/>
<EuiSpacer size="s" />
</>
)}
<EuiHorizontalRule />
{guideConfig?.steps.map((step, index, steps) => {
const accordionId = htmlIdGenerator(`accordion${index}`)();
const stepStatus = getStepStatus(steps, index, guideState?.activeStep);
const stepState = guideState?.steps[index];
if (stepState) {
return (
<GuideStep
accordionId={accordionId}
stepStatus={stepStatus}
stepStatus={stepState.status}
stepConfig={step}
stepNumber={index + 1}
navigateToStep={navigateToStep}
key={accordionId}
/>
);
}
})}
{guideState?.status === 'ready_to_complete' && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton onClick={completeGuide} fill data-test-subj="useElasticButton">
{i18n.translate('guidedOnboarding.dropdownPanel.elasticButtonLabel', {
defaultMessage: 'Continue using Elastic',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
</EuiFlyoutBody>

View file

@ -20,7 +20,8 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { StepStatus, StepConfig } from '../types';
import type { StepStatus, GuideStepIds } from '../../common/types';
import type { StepConfig } from '../types';
import { getGuidePanelStepStyles } from './guide_panel_step.styles';
interface GuideStepProps {
@ -28,7 +29,7 @@ interface GuideStepProps {
stepStatus: StepStatus;
stepConfig: StepConfig;
stepNumber: number;
navigateToStep: (step: StepConfig) => void;
navigateToStep: (stepId: GuideStepIds, stepLocation: StepConfig['location']) => void;
}
export const GuideStep = ({
@ -64,7 +65,7 @@ export const GuideStep = ({
id={accordionId}
buttonContent={buttonContent}
arrowDisplay="right"
forceState={stepStatus === 'in_progress' ? 'open' : 'closed'}
forceState={stepStatus === 'in_progress' || stepStatus === 'active' ? 'open' : 'closed'}
>
<>
<EuiSpacer size="s" />
@ -78,13 +79,20 @@ export const GuideStep = ({
</EuiText>
<EuiSpacer />
{stepStatus === 'in_progress' && (
{(stepStatus === 'in_progress' || stepStatus === 'active') && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton onClick={() => navigateToStep(stepConfig)} fill>
{/* TODO: Support for conditional "Continue" button label if user revists a step - https://github.com/elastic/kibana/issues/139752 */}
{i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
<EuiButton
onClick={() => navigateToStep(stepConfig.id, stepConfig.location)}
fill
data-test-subj="activeStepButtonLabel"
>
{stepStatus === 'active'
? i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
defaultMessage: 'Start',
})
: i18n.translate('guidedOnboarding.dropdownPanel.continueStepButtonLabel', {
defaultMessage: 'Continue',
})}
</EuiButton>
</EuiFlexItem>

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { GuidesConfig } from '../types';
import type { GuidesConfig } from '../../types';
import { securityConfig } from './security';
import { observabilityConfig } from './observability';
import { searchConfig } from './search';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { GuideConfig } from '../types';
import type { GuideConfig } from '../../types';
export const observabilityConfig: GuideConfig = {
title: 'Observe my infrastructure',

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { GuideConfig } from '../types';
import type { GuideConfig } from '../../types';
export const searchConfig: GuideConfig = {
title: 'Search my data',
@ -50,6 +50,10 @@ export const searchConfig: 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.',
],
location: {
appID: 'guidedOnboardingExample',
path: 'stepThree',
},
},
],
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { GuideConfig } from '../types';
import type { GuideConfig } from '../../types';
export const securityConfig: GuideConfig = {
title: 'Get started with SIEM',

View file

@ -12,9 +12,8 @@ import { GuidedOnboardingPlugin } from './plugin';
export function plugin(ctx: PluginInitializerContext) {
return new GuidedOnboardingPlugin(ctx);
}
export type {
GuidedOnboardingPluginSetup,
GuidedOnboardingPluginStart,
GuidedOnboardingState,
UseCase,
} from './types';
export type { GuidedOnboardingPluginSetup, GuidedOnboardingPluginStart } from './types';
export type { GuideId, GuideStepIds, GuideState, GuideStep } from '../common/types';
export { guidesConfig } from './constants/guides_config';

View file

@ -20,7 +20,7 @@ import {
} from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import {
import type {
ClientConfigType,
GuidedOnboardingPluginSetup,
GuidedOnboardingPluginStart,

View file

@ -10,15 +10,33 @@ import { HttpSetup } from '@kbn/core/public';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { firstValueFrom, Subscription } from 'rxjs';
import { API_BASE_PATH } from '../../common';
import { ApiService } from './api';
import { GuidedOnboardingState } from '..';
import { API_BASE_PATH } from '../../common/constants';
import { guidesConfig } from '../constants/guides_config';
import type { GuideState } from '../../common/types';
import { ApiService } from './api';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
const secondStep = guidesConfig[searchGuide].steps[1].id;
const lastStep = guidesConfig[searchGuide].steps[2].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',
},
],
};
describe('GuidedOnboarding ApiService', () => {
let httpClient: jest.Mocked<HttpSetup>;
@ -41,40 +59,67 @@ describe('GuidedOnboarding ApiService', () => {
jest.restoreAllMocks();
});
describe('fetchGuideState$', () => {
describe('fetchActiveGuideState$', () => {
it('sends a request to the get API', () => {
subscription = apiService.fetchGuideState$().subscribe();
subscription = apiService.fetchActiveGuideState$().subscribe();
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`);
expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
query: { active: true },
});
});
it('broadcasts the updated state', async () => {
await apiService.updateGuideState({
activeGuide: searchGuide,
activeStep: secondStep,
await apiService.activateGuide(searchGuide);
const state = await firstValueFrom(apiService.fetchActiveGuideState$());
expect(state).toEqual(mockActiveSearchGuideState);
});
});
const state = await firstValueFrom(apiService.fetchGuideState$());
expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep });
describe('fetchAllGuidesState', () => {
it('sends a request to the get API', async () => {
await apiService.fetchAllGuidesState();
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`);
});
});
describe('updateGuideState', () => {
it('sends a request to the put API', async () => {
const state = {
activeGuide: searchGuide,
activeStep: secondStep,
const updatedState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress', // update the first step status
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
await apiService.updateGuideState(state as GuidedOnboardingState);
await apiService.updateGuideState(updatedState, false);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify(state),
body: JSON.stringify(updatedState),
});
});
});
describe('isGuideStepActive$', () => {
it('returns true if the step is active', async (done) => {
it('returns true if the step has been started', async (done) => {
const updatedState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
await apiService.updateGuideState(updatedState, false);
subscription = apiService
.isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
@ -84,9 +129,10 @@ describe('GuidedOnboarding ApiService', () => {
});
});
it('returns false if the step is not active', async (done) => {
it('returns false if the step is not been started', async (done) => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
subscription = apiService
.isGuideStepActive$(searchGuide, secondStep)
.isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
if (!isStepActive) {
done();
@ -95,40 +141,192 @@ describe('GuidedOnboarding ApiService', () => {
});
});
describe('activateGuide', () => {
it('activates a new guide', async () => {
await apiService.activateGuide(searchGuide);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'active',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
guideId: searchGuide,
}),
});
});
it('reactivates a guide that has already been started', async () => {
await apiService.activateGuide(searchGuide, mockActiveSearchGuideState);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...mockActiveSearchGuideState,
isActive: true,
}),
});
});
});
describe('completeGuide', () => {
const readyToCompleteGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: 'add_data',
status: 'complete',
},
{
id: 'browse_docs',
status: 'complete',
},
{
id: 'search_experience',
status: 'complete',
},
],
};
beforeEach(async () => {
await apiService.updateGuideState(readyToCompleteGuideState, false);
});
it('updates the selected guide and marks it as complete', async () => {
await apiService.completeGuide(searchGuide);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...readyToCompleteGuideState,
isActive: false,
status: 'complete',
}),
});
});
it('returns undefined if the selected guide is not active', async () => {
const completedState = await apiService.completeGuide('observability'); // not active
expect(completedState).not.toBeDefined();
});
it('returns undefined if the selected guide has uncompleted steps', async () => {
const incompleteGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: 'add_data',
status: 'complete',
},
{
id: 'browse_docs',
status: 'complete',
},
{
id: 'search_experience',
status: 'in_progress',
},
],
};
await apiService.updateGuideState(incompleteGuideState, false);
const completedState = await apiService.completeGuide(searchGuide);
expect(completedState).not.toBeDefined();
});
});
describe('startGuideStep', () => {
beforeEach(async () => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
});
it('updates the selected step and marks it as in_progress', async () => {
await apiService.startGuideStep(searchGuide, firstStep);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...mockActiveSearchGuideState,
isActive: true,
status: 'in_progress',
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
}),
});
});
it('returns undefined if the selected guide is not active', async () => {
const startState = await apiService.startGuideStep('observability', 'add_data'); // not active
expect(startState).not.toBeDefined();
});
});
describe('completeGuideStep', () => {
it(`completes the step when it's active`, async () => {
it(`completes the step when it's in progress`, async () => {
const updatedState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.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],
],
};
await apiService.updateGuideState(updatedState, false);
await apiService.completeGuideStep(searchGuide, firstStep);
expect(httpClient.put).toHaveBeenCalledTimes(1);
// this assertion depends on the guides config, we are checking for the next step
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
// Once on update, once on complete
expect(httpClient.put).toHaveBeenCalledTimes(2);
// Verify the completed step now has a "complete" status, and the subsequent step is "active"
expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
activeGuide: searchGuide,
activeStep: secondStep,
...updatedState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'complete',
},
{
id: mockActiveSearchGuideState.steps[1].id,
status: 'active',
},
mockActiveSearchGuideState.steps[2],
],
}),
});
});
it(`completes the guide when the last step is active`, async () => {
httpClient.get.mockResolvedValue({
// this state depends on the guides config
state: { activeGuide: searchGuide, activeStep: lastStep },
it('returns undefined if the selected guide is not active', async () => {
const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active
expect(startState).not.toBeDefined();
});
apiService.setup(httpClient);
await apiService.completeGuideStep(searchGuide, lastStep);
it('does nothing if the step is not in progress', async () => {
await apiService.updateGuideState(mockActiveSearchGuideState, false);
await apiService.completeGuideStep(searchGuide, firstStep);
// Expect only 1 call from updateGuideState()
expect(httpClient.put).toHaveBeenCalledTimes(1);
// this assertion depends on the guides config, we are checking for the last step
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
activeGuide: searchGuide,
activeStep: 'completed',
}),
});
});
it(`does nothing if the step is not active`, async () => {
await apiService.completeGuideStep(searchGuide, secondStep);
expect(httpClient.put).not.toHaveBeenCalled();
});
});
});

View file

@ -9,31 +9,42 @@
import { HttpSetup } from '@kbn/core/public';
import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs';
import { API_BASE_PATH } from '../../common';
import { GuidedOnboardingState, UseCase } from '../types';
import { getNextStep, 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 {
private client: HttpSetup | undefined;
private onboardingGuideState$!: BehaviorSubject<GuidedOnboardingState | undefined>;
private onboardingGuideState$!: BehaviorSubject<GuideState | undefined>;
public isGuidePanelOpen$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public setup(httpClient: HttpSetup): void {
this.client = httpClient;
this.onboardingGuideState$ = new BehaviorSubject<GuidedOnboardingState | undefined>(undefined);
this.onboardingGuideState$ = new BehaviorSubject<GuideState | undefined>(undefined);
}
/**
* An Observable with the guided onboarding state.
* An Observable with the active guide state.
* Initially the state is fetched from the backend.
* Subsequently, the observable is updated automatically, when the state changes.
*/
public fetchGuideState$(): Observable<GuidedOnboardingState> {
public fetchActiveGuideState$(): Observable<GuideState | undefined> {
// TODO add error handling if this.client has not been initialized or request fails
return this.onboardingGuideState$.pipe(
concatMap((state) =>
state === undefined
? from(this.client!.get<{ state: GuidedOnboardingState }>(`${API_BASE_PATH}/state`)).pipe(
map((response) => response.state)
? from(
this.client!.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`, {
query: {
active: true,
},
})
).pipe(
map((response) => {
// There should only be 1 active guide
const hasState = response.state.length === 1;
return hasState ? response.state[0] : undefined;
})
)
: of(state)
)
@ -41,25 +52,45 @@ export class ApiService {
}
/**
* Updates the state of the guided onboarding
* @param {GuidedOnboardingState} newState the new state of the guided onboarding
* @return {Promise} a promise with the updated state or undefined if the update fails
* Async operation to fetch state for all guides
* This is useful for the onboarding landing page,
* where all guides are displayed with their corresponding status
*/
public async updateGuideState(
newState: GuidedOnboardingState
): Promise<{ state: GuidedOnboardingState } | undefined> {
public async fetchAllGuidesState(): Promise<{ state: GuideState[] } | undefined> {
if (!this.client) {
throw new Error('ApiService has not be initialized.');
}
try {
const response = await this.client.put<{ state: GuidedOnboardingState }>(
`${API_BASE_PATH}/state`,
{
body: JSON.stringify(newState),
return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`);
} catch (error) {
// TODO handle error
// eslint-disable-next-line no-console
console.error(error);
}
);
}
/**
* Updates the SO with the updated guide state and refreshes the observables
* This is largely used internally and for tests
* @param {GuideState} guideState the updated guide state
* @param {boolean} panelState boolean to determine whether the dropdown panel should open or not
* @return {Promise} a promise with the updated guide state
*/
public async updateGuideState(
newState: GuideState,
panelState: boolean
): Promise<{ state: GuideState } | undefined> {
if (!this.client) {
throw new Error('ApiService has not be initialized.');
}
try {
const response = await this.client.put<{ state: GuideState }>(`${API_BASE_PATH}/state`, {
body: JSON.stringify(newState),
});
this.onboardingGuideState$.next(newState);
this.isGuidePanelOpen$.next(panelState);
return response;
} catch (error) {
// TODO handle error
@ -69,47 +100,204 @@ export class ApiService {
}
/**
* An observable with the boolean value if the step is active.
* Returns true, if the passed params identify the guide step that is currently active.
* 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)
* @return {Promise} a promise with the updated guide state
*/
public async activateGuide(
guideId: GuideId,
guide?: GuideState
): Promise<{ state: GuideState } | undefined> {
// If we already have the guide state (i.e., user has already started the guide at some point),
// simply pass it through so they can continue where they left off, and update the guide to active
if (guide) {
return await this.updateGuideState(
{
...guide,
isActive: true,
},
true
);
}
// If this is the 1st-time attempt, we need to create the default state
const guideConfig = getGuideConfig(guideId);
if (guideConfig) {
const updatedSteps: GuideStep[] = guideConfig.steps.map((step, stepIndex) => {
const isFirstStep = stepIndex === 0;
return {
id: step.id,
// Only the first step should be activated when activating a new guide
status: isFirstStep ? 'active' : 'inactive',
};
});
const updatedGuide: GuideState = {
isActive: true,
status: 'in_progress',
steps: updatedSteps,
guideId,
};
return await this.updateGuideState(updatedGuide, true);
}
}
/**
* 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)
* @return {Promise} a promise with the updated guide state
*/
public async completeGuide(guideId: GuideId): Promise<{ state: GuideState } | undefined> {
const guideState = await firstValueFrom(this.fetchActiveGuideState$());
// For now, returning undefined if consumer attempts to complete a guide that is not active
if (guideState?.guideId !== guideId) {
return undefined;
}
// All steps should be complete at this point
// However, we do a final check here as a safeguard
const allStepsComplete =
Boolean(guideState.steps.find((step) => step.status !== 'complete')) === false;
if (allStepsComplete) {
const updatedGuide: GuideState = {
...guideState,
isActive: false,
status: 'complete',
};
return await this.updateGuideState(updatedGuide, false);
}
}
/**
* An observable with the boolean value if the step is in progress (i.e., user clicked "Start" on a step).
* Returns true, if the passed params identify the guide step that is currently in progress.
* Returns false otherwise.
* @param {string} guideID the id of the guide (one of search, observability, security)
* @param {string} stepID the id of the step in the guide
* @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {GuideStepIds} stepId the id of the step in the guide
* @return {Observable} an observable with the boolean value
*/
public isGuideStepActive$(guideID: string, stepID: string): Observable<boolean> {
return this.fetchGuideState$().pipe(
map((state) => {
return state ? state.activeGuide === guideID && state.activeStep === stepID : false;
public isGuideStepActive$(guideId: GuideId, stepId: GuideStepIds): Observable<boolean> {
return this.fetchActiveGuideState$().pipe(
map((activeGuideState) => {
// Return false right away if the guide itself is not active
if (activeGuideState?.guideId !== guideId) {
return false;
}
// If the guide is active, next check the step
const selectedStep = activeGuideState.steps.find((step) => step.id === stepId);
return selectedStep ? selectedStep.status === 'in_progress' : false;
})
);
}
/**
* Updates the selected step to 'in_progress' state
* This is useful for the dropdown panel, when the user clicks the "Start" button for the active step
* @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {GuideStepIds} stepId the id of the step
* @return {Promise} a promise with the updated guide state
*/
public async startGuideStep(
guideId: GuideId,
stepId: GuideStepIds
): Promise<{ state: GuideState } | undefined> {
const guideState = await firstValueFrom(this.fetchActiveGuideState$());
// For now, returning undefined if consumer attempts to start a step for a guide that isn't active
if (guideState?.guideId !== guideId) {
return undefined;
}
const updatedSteps: GuideStep[] = guideState.steps.map((step) => {
// Mark the current step as in_progress
if (step.id === stepId) {
return {
id: step.id,
status: 'in_progress',
};
}
// All other steps return as-is
return step;
});
const currentGuide: GuideState = {
guideId,
isActive: true,
status: 'in_progress',
steps: updatedSteps,
};
return await this.updateGuideState(currentGuide, false);
}
/**
* Completes the guide step identified by the passed params.
* A noop if the passed step is not active.
* Completes the current guide, if the step is the last one in the guide.
* @param {string} guideID the id of the guide (one of search, observability, security)
* @param {string} stepID the id of the step in the guide
* @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {GuideStepIds} stepId the id of the step in the guide
* @return {Promise} a promise with the updated state or undefined if the operation fails
*/
public async completeGuideStep(
guideID: string,
stepID: string
): Promise<{ state: GuidedOnboardingState } | undefined> {
const isStepActive = await firstValueFrom(this.isGuideStepActive$(guideID, stepID));
if (isStepActive) {
if (isLastStep(guideID, stepID)) {
await this.updateGuideState({ activeGuide: guideID as UseCase, activeStep: 'completed' });
} else {
const nextStepID = getNextStep(guideID, stepID);
if (nextStepID !== undefined) {
await this.updateGuideState({
activeGuide: guideID as UseCase,
activeStep: nextStepID,
guideId: GuideId,
stepId: GuideStepIds
): Promise<{ state: GuideState } | undefined> {
const guideState = await firstValueFrom(this.fetchActiveGuideState$());
// For now, returning undefined if consumer attempts to complete a step for a guide that isn't active
if (guideState?.guideId !== guideId) {
return undefined;
}
const currentStepIndex = guideState.steps.findIndex((step) => step.id === stepId);
const currentStep = guideState.steps[currentStepIndex];
const isCurrentStepInProgress = currentStep ? currentStep.status === 'in_progress' : false;
if (isCurrentStepInProgress) {
const updatedSteps: GuideStep[] = guideState.steps.map((step, stepIndex) => {
const isCurrentStep = step.id === currentStep!.id;
const isNextStep = stepIndex === currentStepIndex + 1;
// Mark the current step as complete
if (isCurrentStep) {
return {
id: step.id,
status: 'complete',
};
}
// Update the next step to active status
if (isNextStep) {
return {
id: step.id,
status: 'active',
};
}
// All other steps return as-is
return step;
});
const currentGuide: GuideState = {
guideId,
isActive: true,
status: isLastStep(guideId, stepId) ? 'ready_to_complete' : 'in_progress',
steps: updatedSteps,
};
return await this.updateGuideState(currentGuide, true);
}
}
}
return undefined;
}
}

View file

@ -7,11 +7,10 @@
*/
import { guidesConfig } from '../constants/guides_config';
import { getNextStep, isLastStep } from './helpers';
import { isLastStep } from './helpers';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
const secondStep = guidesConfig[searchGuide].steps[1].id;
const lastStep = guidesConfig[searchGuide].steps[2].id;
describe('GuidedOnboarding ApiService helpers', () => {
@ -27,21 +26,4 @@ describe('GuidedOnboarding ApiService helpers', () => {
expect(result).toBe(false);
});
});
describe('getNextStep', () => {
it('returns id of the next step', () => {
const result = getNextStep(searchGuide, firstStep);
expect(result).toEqual(secondStep);
});
it('returns undefined if the params are not part of the config', () => {
const result = getNextStep('some_guide', 'some_step');
expect(result).toBeUndefined();
});
it(`returns undefined if it's the last step`, () => {
const result = getNextStep(searchGuide, lastStep);
expect(result).toBeUndefined();
});
});
});

View file

@ -6,12 +6,13 @@
* Side Public License, v 1.
*/
import type { GuideId } from '../../common/types';
import { guidesConfig } from '../constants/guides_config';
import { GuideConfig, StepConfig, UseCase } from '../types';
import type { GuideConfig, StepConfig } from '../types';
export const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) {
return guidesConfig[guideID as UseCase];
return guidesConfig[guideID as GuideId];
}
};
@ -32,11 +33,3 @@ export const isLastStep = (guideID: string, stepID: string): boolean => {
}
return false;
};
export const getNextStep = (guideID: string, stepID: string): string | undefined => {
const guide = getGuideConfig(guideID);
const activeStepIndex = getStepIndex(guideID, stepID);
if (activeStepIndex > -1 && guide?.steps[activeStepIndex + 1]) {
return guide?.steps[activeStepIndex + 1].id;
}
};

View file

@ -7,6 +7,7 @@
*/
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { GuideId, GuideStepIds, StepStatus } from '../common/types';
import { ApiService } from './services/api';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -20,11 +21,12 @@ export interface AppPluginStartDependencies {
navigation: NavigationPublicPluginStart;
}
export type UseCase = 'observability' | 'security' | 'search';
export type StepStatus = 'incomplete' | 'complete' | 'in_progress';
export interface ClientConfigType {
ui: boolean;
}
export interface StepConfig {
id: string;
id: GuideStepIds;
title: string;
descriptionList: string[];
location?: {
@ -33,7 +35,6 @@ export interface StepConfig {
};
status?: StepStatus;
}
export interface GuideConfig {
title: string;
description: string;
@ -45,14 +46,5 @@ export interface GuideConfig {
}
export type GuidesConfig = {
[key in UseCase]: GuideConfig;
[key in GuideId]: GuideConfig;
};
export interface GuidedOnboardingState {
activeGuide: UseCase | 'unset';
activeStep: string | 'unset' | 'completed';
}
export interface ClientConfigType {
ui: boolean;
}

View file

@ -7,92 +7,154 @@
*/
import { schema } from '@kbn/config-schema';
import { IRouter, SavedObjectsClient } from '@kbn/core/server';
import {
guidedSetupDefaultState,
guidedSetupSavedObjectsId,
guidedSetupSavedObjectsType,
} from '../saved_objects';
import type { IRouter, SavedObjectsClient } from '@kbn/core/server';
import type { GuideState } from '../../common/types';
import { guidedSetupSavedObjectsType } from '../saved_objects';
const doesGuidedSetupExist = async (savedObjectsClient: SavedObjectsClient): Promise<boolean> => {
return savedObjectsClient
.find({ type: guidedSetupSavedObjectsType })
.then((foundSavedObjects) => foundSavedObjects.total > 0);
const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => {
return savedObjectsClient.find<GuideState>({
type: guidedSetupSavedObjectsType,
search: `"${guideId}"`,
searchFields: ['guideId'],
});
};
const findActiveGuide = async (savedObjectsClient: SavedObjectsClient) => {
return savedObjectsClient.find<GuideState>({
type: guidedSetupSavedObjectsType,
search: 'true',
searchFields: ['isActive'],
});
};
const findAllGuides = async (savedObjectsClient: SavedObjectsClient) => {
return savedObjectsClient.find<GuideState>({ type: guidedSetupSavedObjectsType });
};
export function defineRoutes(router: IRouter) {
// Fetch all guides state; optionally pass the query param ?active=true to only return the active guide
router.get(
{
path: '/api/guided_onboarding/state',
validate: false,
validate: {
query: schema.object({
active: schema.maybe(schema.boolean()),
}),
},
},
async (context, request, response) => {
const coreContext = await context.core;
const soClient = coreContext.savedObjects.client as SavedObjectsClient;
const stateExists = await doesGuidedSetupExist(soClient);
if (stateExists) {
const guidedSetupSO = await soClient.get(
guidedSetupSavedObjectsType,
guidedSetupSavedObjectsId
);
const existingGuides =
request.query.active === true
? await findActiveGuide(soClient)
: await findAllGuides(soClient);
if (existingGuides.total > 0) {
const guidesState = existingGuides.saved_objects.map((guide) => guide.attributes);
return response.ok({
body: { state: guidedSetupSO.attributes },
body: { state: guidesState },
});
} else {
// If no SO exists, we assume state hasn't been stored yet and return an empty array
return response.ok({
body: { state: guidedSetupDefaultState },
body: { state: [] },
});
}
}
);
// Update the guide state for the passed guideId;
// will also check any existing active guides and update them to an "inactive" state
router.put(
{
path: '/api/guided_onboarding/state',
validate: {
body: schema.object({
activeGuide: schema.maybe(schema.string()),
activeStep: schema.maybe(schema.string()),
status: schema.string(),
guideId: schema.string(),
isActive: schema.boolean(),
steps: schema.arrayOf(
schema.object({
status: schema.string(),
id: schema.string(),
})
),
}),
},
},
async (context, request, response) => {
const activeGuide = request.body.activeGuide;
const activeStep = request.body.activeStep;
const attributes = {
activeGuide: activeGuide ?? 'unset',
activeStep: activeStep ?? 'unset',
};
const updatedGuideState = request.body;
const coreContext = await context.core;
const soClient = coreContext.savedObjects.client as SavedObjectsClient;
const savedObjectsClient = coreContext.savedObjects.client as SavedObjectsClient;
const stateExists = await doesGuidedSetupExist(soClient);
const selectedGuideSO = await findGuideById(savedObjectsClient, updatedGuideState.guideId);
// If the SO already exists, update it, else create a new SO
if (selectedGuideSO.total > 0) {
const updatedGuides = [];
const selectedGuide = selectedGuideSO.saved_objects[0];
updatedGuides.push({
type: guidedSetupSavedObjectsType,
id: selectedGuide.id,
attributes: {
...updatedGuideState,
},
});
// If we are activating a new guide, we need to check if there is a different, existing active guide
// If yes, we need to mark it as inactive (only 1 guide can be active at a time)
if (updatedGuideState.isActive) {
const activeGuideSO = await findActiveGuide(savedObjectsClient);
if (activeGuideSO.total > 0) {
const activeGuide = activeGuideSO.saved_objects[0];
if (activeGuide.attributes.guideId !== updatedGuideState.guideId) {
updatedGuides.push({
type: guidedSetupSavedObjectsType,
id: activeGuide.id,
attributes: {
...activeGuide.attributes,
isActive: false,
},
});
}
}
}
const updatedGuidesResponse = await savedObjectsClient.bulkUpdate(updatedGuides);
if (stateExists) {
const updatedGuidedSetupSO = await soClient.update(
guidedSetupSavedObjectsType,
guidedSetupSavedObjectsId,
attributes
);
return response.ok({
body: { state: updatedGuidedSetupSO.attributes },
body: {
state: updatedGuidesResponse,
},
});
} else {
const guidedSetupSO = await soClient.create(
guidedSetupSavedObjectsType,
{
...guidedSetupDefaultState,
...attributes,
},
{
id: guidedSetupSavedObjectsId,
// If we are activating a new guide, we need to check if there is an existing active guide
// If yes, we need to mark it as inactive (only 1 guide can be active at a time)
if (updatedGuideState.isActive) {
const activeGuideSO = await findActiveGuide(savedObjectsClient);
if (activeGuideSO.total > 0) {
const activeGuide = activeGuideSO.saved_objects[0];
await savedObjectsClient.update(guidedSetupSavedObjectsType, activeGuide.id, {
...activeGuide.attributes,
isActive: false,
});
}
}
const createdGuideResponse = await savedObjectsClient.create(
guidedSetupSavedObjectsType,
updatedGuideState
);
return response.ok({
body: {
state: guidedSetupSO.attributes,
state: createdGuideResponse,
},
});
}

View file

@ -8,12 +8,8 @@
import { SavedObjectsType } from '@kbn/core/server';
export const guidedSetupSavedObjectsType = 'guided-setup-state';
export const guidedSetupSavedObjectsId = 'guided-setup-state-id';
export const guidedSetupDefaultState = {
activeGuide: 'unset',
activeStep: 'unset',
};
export const guidedSetupSavedObjectsType = 'guided-onboarding-guide-state';
export const guidedSetupSavedObjects: SavedObjectsType = {
name: guidedSetupSavedObjectsType,
hidden: false,
@ -22,11 +18,11 @@ export const guidedSetupSavedObjects: SavedObjectsType = {
mappings: {
dynamic: false,
properties: {
activeGuide: {
guideId: {
type: 'keyword',
},
activeStep: {
type: 'keyword',
isActive: {
type: 'boolean',
},
},
},

View file

@ -6,9 +6,4 @@
* Side Public License, v 1.
*/
export {
guidedSetupSavedObjects,
guidedSetupSavedObjectsType,
guidedSetupSavedObjectsId,
guidedSetupDefaultState,
} from './guided_setup';
export { guidedSetupSavedObjects, guidedSetupSavedObjectsType } from './guided_setup';