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

View file

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

View file

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

View file

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

View file

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

View file

@ -60,6 +60,7 @@ const previouslyRegisteredTypes = [
'fleet-preconfiguration-deletion-record', 'fleet-preconfiguration-deletion-record',
'graph-workspace', 'graph-workspace',
'guided-setup-state', 'guided-setup-state',
'guided-onboarding-guide-state',
'index-pattern', 'index-pattern',
'infrastructure-monitoring-log-view', 'infrastructure-monitoring-log-view',
'infrastructure-ui-source', '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 { httpServiceMock } from '@kbn/core/public/mocks';
import { HttpSetup } from '@kbn/core/public'; import { HttpSetup } from '@kbn/core/public';
import { apiService } from '../services/api';
import { guidesConfig } from '../constants/guides_config'; import { guidesConfig } from '../constants/guides_config';
import type { GuideState } from '../../common/types';
import { apiService } from '../services/api';
import { GuidePanel } from './guide_panel'; import { GuidePanel } from './guide_panel';
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
const applicationMock = applicationServiceMock.createStartContract(); 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 = () => () => { const getGuidePanel = () => () => {
return <GuidePanel application={applicationMock} api={apiService} />; return <GuidePanel application={applicationMock} api={apiService} />;
}; };
describe('GuidePanel', () => { describe('Guided setup', () => {
let httpClient: jest.Mocked<HttpSetup>; let httpClient: jest.Mocked<HttpSetup>;
let testBed: TestBed; let testBed: TestBed;
@ -32,7 +53,7 @@ describe('GuidePanel', () => {
httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' });
// Set default state on initial request (no active guides) // Set default state on initial request (no active guides)
httpClient.get.mockResolvedValue({ httpClient.get.mockResolvedValue({
state: { activeGuide: 'unset', activeStep: 'unset' }, state: [],
}); });
apiService.setup(httpClient); apiService.setup(httpClient);
@ -48,29 +69,164 @@ describe('GuidePanel', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
test('it should be disabled in there is no active guide', async () => { describe('Button component', () => {
const { exists } = testBed; test('should be disabled in there is no active guide', async () => {
expect(exists('disabledGuideButton')).toBe(true); const { exists } = testBed;
expect(exists('guideButton')).toBe(false); expect(exists('disabledGuideButton')).toBe(true);
expect(exists('guidePanel')).toBe(false); expect(exists('guideButton')).toBe(false);
}); expect(exists('guidePanel')).toBe(false);
test('it 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,
});
}); });
component.update(); test('should be enabled if there is an active guide', async () => {
const { exists, component, find } = testBed;
expect(exists('disabledGuideButton')).toBe(false); await act(async () => {
expect(exists('guideButton')).toBe(true); // Enable the "search" guide
expect(exists('guidePanel')).toBe(true); await apiService.updateGuideState(mockActiveSearchGuideState, true);
expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length); });
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. * Side Public License, v 1.
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
EuiFlyout, EuiFlyout,
EuiFlyoutBody, EuiFlyoutBody,
@ -30,7 +30,9 @@ import { ApplicationStart } from '@kbn/core-application-browser';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { guidesConfig } from '../constants/guides_config'; 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 type { ApiService } from '../services/api';
import { GuideStep } from './guide_panel_step'; import { GuideStep } from './guide_panel_step';
@ -41,47 +43,48 @@ interface GuidePanelProps {
application: ApplicationStart; application: ApplicationStart;
} }
const getConfig = (state?: GuidedOnboardingState): GuideConfig | undefined => { const getConfig = (state?: GuideState): GuideConfig | undefined => {
if (state?.activeGuide && state.activeGuide !== 'unset') { if (state) {
return guidesConfig[state.activeGuide]; return guidesConfig[state.guideId];
} }
return undefined; return undefined;
}; };
const getCurrentStep = ( const getStepNumber = (state?: GuideState): number | undefined => {
steps?: StepConfig[], let stepNumber: number | undefined;
state?: GuidedOnboardingState
): number | undefined => { state?.steps.forEach((step, stepIndex) => {
if (steps && state?.activeStep) { // If the step is in_progress, show that step number
const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === state.activeStep); if (step.status === 'in_progress') {
if (activeStepIndex > -1) { stepNumber = stepIndex + 1;
return activeStepIndex + 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 getProgress = (state?: GuideState): number => {
const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === activeStep); if (state) {
return state.steps.reduce((acc, currentVal) => {
if (activeStepIndex < stepIndex) { if (currentVal.status === 'complete') {
return 'incomplete'; acc = acc + 1;
}
return acc;
}, 0);
} }
return 0;
if (activeStepIndex === stepIndex) {
return 'in_progress';
}
return 'complete';
}; };
export const GuidePanel = ({ api, application }: GuidePanelProps) => { export const GuidePanel = ({ api, application }: GuidePanelProps) => {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
const [isGuideOpen, setIsGuideOpen] = useState(false); const [isGuideOpen, setIsGuideOpen] = useState(false);
const [guideState, setGuideState] = useState<GuidedOnboardingState | undefined>(undefined); const [guideState, setGuideState] = useState<GuideState | undefined>(undefined);
const isFirstRender = useRef(true);
const styles = getGuidePanelStyles(euiTheme); const styles = getGuidePanelStyles(euiTheme);
@ -89,10 +92,10 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen); setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen);
}; };
const navigateToStep = (step: StepConfig) => { const navigateToStep = async (stepId: GuideStepIds, stepLocation: StepConfig['location']) => {
setIsGuideOpen(false); await api.startGuideStep(guideState!.guideId, stepId);
if (step.location) { if (stepLocation) {
application.navigateToApp(step.location.appID, { path: step.location.path }); application.navigateToApp(stepLocation.appID, { path: stepLocation.path });
} }
}; };
@ -101,22 +104,25 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
application.navigateToApp('home', { path: '#getting_started' }); application.navigateToApp('home', { path: '#getting_started' });
}; };
const completeGuide = async () => {
await api.completeGuide(guideState!.guideId);
};
useEffect(() => { useEffect(() => {
const subscription = api.fetchGuideState$().subscribe((newState) => { const subscription = api.fetchActiveGuideState$().subscribe((newGuideState) => {
if ( if (newGuideState) {
guideState?.activeGuide !== newState.activeGuide || setGuideState(newGuideState);
guideState?.activeStep !== newState.activeStep
) {
if (isFirstRender.current) {
isFirstRender.current = false;
} else {
setIsGuideOpen(true);
}
} }
setGuideState(newState);
}); });
return () => subscription.unsubscribe(); 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); 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 ( return (
<> <>
<EuiButton onClick={toggleGuide} color="success" fill size="s" data-test-subj="guideButton"> <EuiButton onClick={toggleGuide} color="success" fill size="s" data-test-subj="guideButton">
{currentStep {Boolean(stepNumber)
? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', { ? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', {
defaultMessage: 'Setup guide: Step {currentStep}', defaultMessage: 'Setup guide: step {stepNumber}',
values: { values: {
currentStep, stepNumber,
}, },
}) })
: i18n.translate('guidedOnboarding.guidedSetupButtonLabel', { : i18n.translate('guidedOnboarding.guidedSetupButtonLabel', {
@ -203,46 +210,61 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
</> </>
)} )}
<EuiSpacer size="xl" /> {/* Progress bar should only show after the first step has been complete */}
{stepsCompleted > 0 && (
<>
<EuiSpacer size="xl" />
<EuiProgress
data-test-subj="guideProgress"
label={i18n.translate('guidedOnboarding.dropdownPanel.progressLabel', {
defaultMessage: 'Progress',
})}
value={stepsCompleted}
valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', {
defaultMessage: '{stepCount} steps',
values: {
stepCount: `${stepsCompleted} / ${guideConfig.steps.length}`,
},
})}
max={guideConfig.steps.length}
size="l"
/>
{/* <EuiSpacer size="s" />
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
label={i18n.translate('guidedOnboarding.dropdownPanel.progressLabel', {
defaultMessage: 'Progress',
})}
value={currentStep ? currentStep - 1 : 0}
valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', {
defaultMessage: '{stepCount} steps',
values: {
stepCount: `${currentStep ? currentStep - 1 : 0} / ${guideConfig.steps.length}`,
},
})}
max={guideConfig.steps.length}
size="l"
/>
<EuiSpacer size="s" />
<EuiHorizontalRule /> <EuiHorizontalRule />
{guideConfig?.steps.map((step, index, steps) => { {guideConfig?.steps.map((step, index, steps) => {
const accordionId = htmlIdGenerator(`accordion${index}`)(); const accordionId = htmlIdGenerator(`accordion${index}`)();
const stepStatus = getStepStatus(steps, index, guideState?.activeStep); const stepState = guideState?.steps[index];
return ( if (stepState) {
<GuideStep return (
accordionId={accordionId} <GuideStep
stepStatus={stepStatus} accordionId={accordionId}
stepConfig={step} stepStatus={stepState.status}
stepNumber={index + 1} stepConfig={step}
navigateToStep={navigateToStep} stepNumber={index + 1}
key={accordionId} 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> </div>
</EuiFlyoutBody> </EuiFlyoutBody>

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import type { GuideConfig } from '../types'; import type { GuideConfig } from '../../types';
export const searchConfig: GuideConfig = { export const searchConfig: GuideConfig = {
title: 'Search my data', title: 'Search my data',
@ -50,6 +50,10 @@ export const searchConfig: GuideConfig = {
'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', '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.', '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. * Side Public License, v 1.
*/ */
import type { GuideConfig } from '../types'; import type { GuideConfig } from '../../types';
export const securityConfig: GuideConfig = { export const securityConfig: GuideConfig = {
title: 'Get started with SIEM', title: 'Get started with SIEM',

View file

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

View file

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

View file

@ -10,15 +10,33 @@ import { HttpSetup } from '@kbn/core/public';
import { httpServiceMock } from '@kbn/core/public/mocks'; import { httpServiceMock } from '@kbn/core/public/mocks';
import { firstValueFrom, Subscription } from 'rxjs'; import { firstValueFrom, Subscription } from 'rxjs';
import { API_BASE_PATH } from '../../common'; import { API_BASE_PATH } from '../../common/constants';
import { ApiService } from './api';
import { GuidedOnboardingState } from '..';
import { guidesConfig } from '../constants/guides_config'; import { guidesConfig } from '../constants/guides_config';
import type { GuideState } from '../../common/types';
import { ApiService } from './api';
const searchGuide = 'search'; const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id; 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', () => { describe('GuidedOnboarding ApiService', () => {
let httpClient: jest.Mocked<HttpSetup>; let httpClient: jest.Mocked<HttpSetup>;
@ -41,40 +59,67 @@ describe('GuidedOnboarding ApiService', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
describe('fetchGuideState$', () => { describe('fetchActiveGuideState$', () => {
it('sends a request to the get API', () => { it('sends a request to the get API', () => {
subscription = apiService.fetchGuideState$().subscribe(); subscription = apiService.fetchActiveGuideState$().subscribe();
expect(httpClient.get).toHaveBeenCalledTimes(1); 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 () => { it('broadcasts the updated state', async () => {
await apiService.updateGuideState({ await apiService.activateGuide(searchGuide);
activeGuide: searchGuide,
activeStep: secondStep,
});
const state = await firstValueFrom(apiService.fetchGuideState$()); const state = await firstValueFrom(apiService.fetchActiveGuideState$());
expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep }); expect(state).toEqual(mockActiveSearchGuideState);
});
});
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', () => { describe('updateGuideState', () => {
it('sends a request to the put API', async () => { it('sends a request to the put API', async () => {
const state = { const updatedState: GuideState = {
activeGuide: searchGuide, ...mockActiveSearchGuideState,
activeStep: secondStep, 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).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify(state), body: JSON.stringify(updatedState),
}); });
}); });
}); });
describe('isGuideStepActive$', () => { 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 subscription = apiService
.isGuideStepActive$(searchGuide, firstStep) .isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => { .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 subscription = apiService
.isGuideStepActive$(searchGuide, secondStep) .isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => { .subscribe((isStepActive) => {
if (!isStepActive) { if (!isStepActive) {
done(); 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', () => { 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); await apiService.completeGuideStep(searchGuide, firstStep);
expect(httpClient.put).toHaveBeenCalledTimes(1);
// this assertion depends on the guides config, we are checking for the next step // Once on update, once on complete
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { 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({ body: JSON.stringify({
activeGuide: searchGuide, ...updatedState,
activeStep: secondStep, 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 () => { it('returns undefined if the selected guide is not active', async () => {
httpClient.get.mockResolvedValue({ const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active
// this state depends on the guides config expect(startState).not.toBeDefined();
state: { activeGuide: searchGuide, activeStep: lastStep },
});
apiService.setup(httpClient);
await apiService.completeGuideStep(searchGuide, lastStep);
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 () => { it('does nothing if the step is not in progress', async () => {
await apiService.completeGuideStep(searchGuide, secondStep); await apiService.updateGuideState(mockActiveSearchGuideState, false);
expect(httpClient.put).not.toHaveBeenCalled();
await apiService.completeGuideStep(searchGuide, firstStep);
// Expect only 1 call from updateGuideState()
expect(httpClient.put).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

View file

@ -9,31 +9,42 @@
import { HttpSetup } from '@kbn/core/public'; import { HttpSetup } from '@kbn/core/public';
import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs'; import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs';
import { API_BASE_PATH } from '../../common'; import { API_BASE_PATH } from '../../common/constants';
import { GuidedOnboardingState, UseCase } from '../types'; import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types';
import { getNextStep, isLastStep } from './helpers'; import { isLastStep, getGuideConfig } from './helpers';
export class ApiService { export class ApiService {
private client: HttpSetup | undefined; 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 { public setup(httpClient: HttpSetup): void {
this.client = httpClient; 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. * Initially the state is fetched from the backend.
* Subsequently, the observable is updated automatically, when the state changes. * 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 // TODO add error handling if this.client has not been initialized or request fails
return this.onboardingGuideState$.pipe( return this.onboardingGuideState$.pipe(
concatMap((state) => concatMap((state) =>
state === undefined state === undefined
? from(this.client!.get<{ state: GuidedOnboardingState }>(`${API_BASE_PATH}/state`)).pipe( ? from(
map((response) => response.state) 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) : of(state)
) )
@ -41,25 +52,45 @@ export class ApiService {
} }
/** /**
* Updates the state of the guided onboarding * Async operation to fetch state for all guides
* @param {GuidedOnboardingState} newState the new state of the guided onboarding * This is useful for the onboarding landing page,
* @return {Promise} a promise with the updated state or undefined if the update fails * where all guides are displayed with their corresponding status
*/ */
public async updateGuideState( public async fetchAllGuidesState(): Promise<{ state: GuideState[] } | undefined> {
newState: GuidedOnboardingState
): Promise<{ state: GuidedOnboardingState } | undefined> {
if (!this.client) { if (!this.client) {
throw new Error('ApiService has not be initialized.'); throw new Error('ApiService has not be initialized.');
} }
try { try {
const response = await this.client.put<{ state: GuidedOnboardingState }>( return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`);
`${API_BASE_PATH}/state`, } catch (error) {
{ // TODO handle error
body: JSON.stringify(newState), // 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.onboardingGuideState$.next(newState);
this.isGuidePanelOpen$.next(panelState);
return response; return response;
} catch (error) { } catch (error) {
// TODO handle error // TODO handle error
@ -69,47 +100,204 @@ export class ApiService {
} }
/** /**
* An observable with the boolean value if the step is active. * Activates a guide by guideId
* Returns true, if the passed params identify the guide step that is currently active. * 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. * Returns false otherwise.
* @param {string} guideID the id of the guide (one of search, observability, security) * @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {string} stepID the id of the step in the guide * @param {GuideStepIds} stepId the id of the step in the guide
* @return {Observable} an observable with the boolean value * @return {Observable} an observable with the boolean value
*/ */
public isGuideStepActive$(guideID: string, stepID: string): Observable<boolean> { public isGuideStepActive$(guideId: GuideId, stepId: GuideStepIds): Observable<boolean> {
return this.fetchGuideState$().pipe( return this.fetchActiveGuideState$().pipe(
map((state) => { map((activeGuideState) => {
return state ? state.activeGuide === guideID && state.activeStep === stepID : false; // 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. * Completes the guide step identified by the passed params.
* A noop if the passed step is not active. * A noop if the passed step is not active.
* Completes the current guide, if the step is the last one in the guide. * @param {GuideId} guideId the id of the guide (one of search, observability, security)
* @param {string} guideID the id of the guide (one of search, observability, security) * @param {GuideStepIds} stepId the id of the step in the guide
* @param {string} stepID the id of the step in the guide
* @return {Promise} a promise with the updated state or undefined if the operation fails * @return {Promise} a promise with the updated state or undefined if the operation fails
*/ */
public async completeGuideStep( public async completeGuideStep(
guideID: string, guideId: GuideId,
stepID: string stepId: GuideStepIds
): Promise<{ state: GuidedOnboardingState } | undefined> { ): Promise<{ state: GuideState } | undefined> {
const isStepActive = await firstValueFrom(this.isGuideStepActive$(guideID, stepID)); const guideState = await firstValueFrom(this.fetchActiveGuideState$());
if (isStepActive) {
if (isLastStep(guideID, stepID)) { // For now, returning undefined if consumer attempts to complete a step for a guide that isn't active
await this.updateGuideState({ activeGuide: guideID as UseCase, activeStep: 'completed' }); if (guideState?.guideId !== guideId) {
} else { return undefined;
const nextStepID = getNextStep(guideID, stepID);
if (nextStepID !== undefined) {
await this.updateGuideState({
activeGuide: guideID as UseCase,
activeStep: nextStepID,
});
}
}
} }
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; return undefined;
} }
} }

View file

@ -7,11 +7,10 @@
*/ */
import { guidesConfig } from '../constants/guides_config'; import { guidesConfig } from '../constants/guides_config';
import { getNextStep, isLastStep } from './helpers'; import { isLastStep } from './helpers';
const searchGuide = 'search'; const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id; const firstStep = guidesConfig[searchGuide].steps[0].id;
const secondStep = guidesConfig[searchGuide].steps[1].id;
const lastStep = guidesConfig[searchGuide].steps[2].id; const lastStep = guidesConfig[searchGuide].steps[2].id;
describe('GuidedOnboarding ApiService helpers', () => { describe('GuidedOnboarding ApiService helpers', () => {
@ -27,21 +26,4 @@ describe('GuidedOnboarding ApiService helpers', () => {
expect(result).toBe(false); 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. * Side Public License, v 1.
*/ */
import type { GuideId } from '../../common/types';
import { guidesConfig } from '../constants/guides_config'; 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 => { export const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) { 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; 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 { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { GuideId, GuideStepIds, StepStatus } from '../common/types';
import { ApiService } from './services/api'; import { ApiService } from './services/api';
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -20,11 +21,12 @@ export interface AppPluginStartDependencies {
navigation: NavigationPublicPluginStart; navigation: NavigationPublicPluginStart;
} }
export type UseCase = 'observability' | 'security' | 'search'; export interface ClientConfigType {
export type StepStatus = 'incomplete' | 'complete' | 'in_progress'; ui: boolean;
}
export interface StepConfig { export interface StepConfig {
id: string; id: GuideStepIds;
title: string; title: string;
descriptionList: string[]; descriptionList: string[];
location?: { location?: {
@ -33,7 +35,6 @@ export interface StepConfig {
}; };
status?: StepStatus; status?: StepStatus;
} }
export interface GuideConfig { export interface GuideConfig {
title: string; title: string;
description: string; description: string;
@ -45,14 +46,5 @@ export interface GuideConfig {
} }
export type GuidesConfig = { 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 { schema } from '@kbn/config-schema';
import { IRouter, SavedObjectsClient } from '@kbn/core/server'; import type { IRouter, SavedObjectsClient } from '@kbn/core/server';
import { import type { GuideState } from '../../common/types';
guidedSetupDefaultState, import { guidedSetupSavedObjectsType } from '../saved_objects';
guidedSetupSavedObjectsId,
guidedSetupSavedObjectsType,
} from '../saved_objects';
const doesGuidedSetupExist = async (savedObjectsClient: SavedObjectsClient): Promise<boolean> => { const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => {
return savedObjectsClient return savedObjectsClient.find<GuideState>({
.find({ type: guidedSetupSavedObjectsType }) type: guidedSetupSavedObjectsType,
.then((foundSavedObjects) => foundSavedObjects.total > 0); 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) { export function defineRoutes(router: IRouter) {
// Fetch all guides state; optionally pass the query param ?active=true to only return the active guide
router.get( router.get(
{ {
path: '/api/guided_onboarding/state', path: '/api/guided_onboarding/state',
validate: false, validate: {
query: schema.object({
active: schema.maybe(schema.boolean()),
}),
},
}, },
async (context, request, response) => { async (context, request, response) => {
const coreContext = await context.core; const coreContext = await context.core;
const soClient = coreContext.savedObjects.client as SavedObjectsClient; const soClient = coreContext.savedObjects.client as SavedObjectsClient;
const stateExists = await doesGuidedSetupExist(soClient); const existingGuides =
if (stateExists) { request.query.active === true
const guidedSetupSO = await soClient.get( ? await findActiveGuide(soClient)
guidedSetupSavedObjectsType, : await findAllGuides(soClient);
guidedSetupSavedObjectsId
); if (existingGuides.total > 0) {
const guidesState = existingGuides.saved_objects.map((guide) => guide.attributes);
return response.ok({ return response.ok({
body: { state: guidedSetupSO.attributes }, body: { state: guidesState },
}); });
} else { } else {
// If no SO exists, we assume state hasn't been stored yet and return an empty array
return response.ok({ 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( router.put(
{ {
path: '/api/guided_onboarding/state', path: '/api/guided_onboarding/state',
validate: { validate: {
body: schema.object({ body: schema.object({
activeGuide: schema.maybe(schema.string()), status: schema.string(),
activeStep: schema.maybe(schema.string()), guideId: schema.string(),
isActive: schema.boolean(),
steps: schema.arrayOf(
schema.object({
status: schema.string(),
id: schema.string(),
})
),
}), }),
}, },
}, },
async (context, request, response) => { async (context, request, response) => {
const activeGuide = request.body.activeGuide; const updatedGuideState = request.body;
const activeStep = request.body.activeStep;
const attributes = {
activeGuide: activeGuide ?? 'unset',
activeStep: activeStep ?? 'unset',
};
const coreContext = await context.core; 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({ return response.ok({
body: { state: updatedGuidedSetupSO.attributes }, body: {
state: updatedGuidesResponse,
},
}); });
} else { } else {
const guidedSetupSO = await soClient.create( // If we are activating a new guide, we need to check if there is an existing active guide
guidedSetupSavedObjectsType, // If yes, we need to mark it as inactive (only 1 guide can be active at a time)
{ if (updatedGuideState.isActive) {
...guidedSetupDefaultState, const activeGuideSO = await findActiveGuide(savedObjectsClient);
...attributes,
}, if (activeGuideSO.total > 0) {
{ const activeGuide = activeGuideSO.saved_objects[0];
id: guidedSetupSavedObjectsId, await savedObjectsClient.update(guidedSetupSavedObjectsType, activeGuide.id, {
...activeGuide.attributes,
isActive: false,
});
} }
}
const createdGuideResponse = await savedObjectsClient.create(
guidedSetupSavedObjectsType,
updatedGuideState
); );
return response.ok({ return response.ok({
body: { body: {
state: guidedSetupSO.attributes, state: createdGuideResponse,
}, },
}); });
} }

View file

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

View file

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