mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Guided onboarding] State management improvements (#141278)
This commit is contained in:
parent
8ad95df6fa
commit
059fecd311
27 changed files with 1225 additions and 383 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
<EuiFlexItem>
|
||||
<EuiButton onClick={() => startGuide('search')} fill>
|
||||
<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"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
{(Object.keys(guidesConfig) as GuideId[]).map((guideId) => {
|
||||
const guideState = guidesState?.find((guide) => guide.guideId === guideId);
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiButton
|
||||
onClick={() => activateGuide(guideId, guideState)}
|
||||
fill
|
||||
disabled={guideState?.status === 'complete'}
|
||||
>
|
||||
{guideState === undefined && (
|
||||
<FormattedMessage
|
||||
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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -107,6 +107,11 @@ describe('createInitialState', () => {
|
|||
"type": "fleet-enrollment-api-keys",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"type": "guided-setup-state",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"type": "ml-telemetry",
|
||||
|
|
|
@ -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',
|
||||
|
|
44
src/plugins/guided_onboarding/common/types.ts
Normal file
44
src/plugins/guided_onboarding/common/types.ts
Normal 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[];
|
||||
}
|
|
@ -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 () => {
|
||||
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 () => {
|
||||
const { exists, component, find } = testBed;
|
||||
|
||||
await act(async () => {
|
||||
// Enable the "search" guide
|
||||
await apiService.updateGuideState({
|
||||
activeGuide: 'search',
|
||||
activeStep: guidesConfig.search.steps[0].id,
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
component.update();
|
||||
test('should be enabled if there is an active guide', async () => {
|
||||
const { exists, component, find } = testBed;
|
||||
|
||||
expect(exists('disabledGuideButton')).toBe(false);
|
||||
expect(exists('guideButton')).toBe(true);
|
||||
expect(exists('guidePanel')).toBe(true);
|
||||
expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length);
|
||||
await act(async () => {
|
||||
// Enable the "search" guide
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (activeStepIndex === stepIndex) {
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
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,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"
|
||||
/>
|
||||
|
||||
{/*
|
||||
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" />
|
||||
<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];
|
||||
|
||||
return (
|
||||
<GuideStep
|
||||
accordionId={accordionId}
|
||||
stepStatus={stepStatus}
|
||||
stepConfig={step}
|
||||
stepNumber={index + 1}
|
||||
navigateToStep={navigateToStep}
|
||||
key={accordionId}
|
||||
/>
|
||||
);
|
||||
if (stepState) {
|
||||
return (
|
||||
<GuideStep
|
||||
accordionId={accordionId}
|
||||
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>
|
||||
|
||||
|
|
|
@ -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,14 +79,21 @@ 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', {
|
||||
defaultMessage: 'Start',
|
||||
})}
|
||||
<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>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -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';
|
|
@ -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',
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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',
|
|
@ -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';
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '@kbn/core/public';
|
||||
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import {
|
||||
import type {
|
||||
ClientConfigType,
|
||||
GuidedOnboardingPluginSetup,
|
||||
GuidedOnboardingPluginStart,
|
||||
|
|
|
@ -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.fetchGuideState$());
|
||||
expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep });
|
||||
const state = await firstValueFrom(apiService.fetchActiveGuideState$());
|
||||
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', () => {
|
||||
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 },
|
||||
});
|
||||
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('returns undefined if the selected guide is not active', async () => {
|
||||
const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active
|
||||
expect(startState).not.toBeDefined();
|
||||
});
|
||||
|
||||
it(`does nothing if the step is not active`, async () => {
|
||||
await apiService.completeGuideStep(searchGuide, secondStep);
|
||||
expect(httpClient.put).not.toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -6,9 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export {
|
||||
guidedSetupSavedObjects,
|
||||
guidedSetupSavedObjectsType,
|
||||
guidedSetupSavedObjectsId,
|
||||
guidedSetupDefaultState,
|
||||
} from './guided_setup';
|
||||
export { guidedSetupSavedObjects, guidedSetupSavedObjectsType } from './guided_setup';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue