mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -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 { 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
{(Object.keys(guidesConfig) as GuideId[]).map((guideId) => {
|
||||||
|
const guideState = guidesState?.find((guide) => guide.guideId === guideId);
|
||||||
|
return (
|
||||||
<EuiFlexItem>
|
<EuiFlexItem>
|
||||||
<EuiButton onClick={() => startGuide('search')} fill>
|
<EuiButton
|
||||||
|
onClick={() => activateGuide(guideId, guideState)}
|
||||||
|
fill
|
||||||
|
disabled={guideState?.status === 'complete'}
|
||||||
|
>
|
||||||
|
{guideState === undefined && (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="guidedOnboardingExample.guidesSelection.search.buttonLabel"
|
id="guidedOnboardingExample.guidesSelection.startButtonLabel"
|
||||||
defaultMessage="(Re-)Start search guide"
|
defaultMessage="Start {guideId} guide"
|
||||||
/>
|
values={{
|
||||||
</EuiButton>
|
guideId,
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{(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>
|
</EuiButton>
|
||||||
</EuiFlexItem>
|
</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>
|
||||||
|
|
|
@ -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
|
<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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
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 { 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', () => {
|
||||||
|
test('should be disabled in there is no active guide', async () => {
|
||||||
const { exists } = testBed;
|
const { exists } = testBed;
|
||||||
expect(exists('disabledGuideButton')).toBe(true);
|
expect(exists('disabledGuideButton')).toBe(true);
|
||||||
expect(exists('guideButton')).toBe(false);
|
expect(exists('guideButton')).toBe(false);
|
||||||
expect(exists('guidePanel')).toBe(false);
|
expect(exists('guidePanel')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it should be enabled if there is an active guide', async () => {
|
test('should be enabled if there is an active guide', async () => {
|
||||||
const { exists, component, find } = testBed;
|
const { exists, component, find } = testBed;
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
// Enable the "search" guide
|
// Enable the "search" guide
|
||||||
await apiService.updateGuideState({
|
await apiService.updateGuideState(mockActiveSearchGuideState, true);
|
||||||
activeGuide: 'search',
|
|
||||||
activeStep: guidesConfig.search.steps[0].id,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
component.update();
|
component.update();
|
||||||
|
|
||||||
expect(exists('disabledGuideButton')).toBe(false);
|
expect(exists('disabledGuideButton')).toBe(false);
|
||||||
expect(exists('guideButton')).toBe(true);
|
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('guidePanel')).toBe(true);
|
||||||
|
expect(exists('guideProgress')).toBe(false);
|
||||||
expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length);
|
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.
|
* 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;
|
||||||
if (activeStepIndex === stepIndex) {
|
}, 0);
|
||||||
return 'in_progress';
|
|
||||||
}
|
}
|
||||||
|
return 0;
|
||||||
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,21 +210,20 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar should only show after the first step has been complete */}
|
||||||
|
{stepsCompleted > 0 && (
|
||||||
|
<>
|
||||||
<EuiSpacer size="xl" />
|
<EuiSpacer size="xl" />
|
||||||
|
|
||||||
{/*
|
|
||||||
TODO: Progress bar should only show after the first step has been started
|
|
||||||
We need to make changes to the state itself in order to support this
|
|
||||||
*/}
|
|
||||||
<EuiProgress
|
<EuiProgress
|
||||||
|
data-test-subj="guideProgress"
|
||||||
label={i18n.translate('guidedOnboarding.dropdownPanel.progressLabel', {
|
label={i18n.translate('guidedOnboarding.dropdownPanel.progressLabel', {
|
||||||
defaultMessage: 'Progress',
|
defaultMessage: 'Progress',
|
||||||
})}
|
})}
|
||||||
value={currentStep ? currentStep - 1 : 0}
|
value={stepsCompleted}
|
||||||
valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', {
|
valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', {
|
||||||
defaultMessage: '{stepCount} steps',
|
defaultMessage: '{stepCount} steps',
|
||||||
values: {
|
values: {
|
||||||
stepCount: `${currentStep ? currentStep - 1 : 0} / ${guideConfig.steps.length}`,
|
stepCount: `${stepsCompleted} / ${guideConfig.steps.length}`,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
max={guideConfig.steps.length}
|
max={guideConfig.steps.length}
|
||||||
|
@ -225,24 +231,40 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EuiSpacer size="s" />
|
<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];
|
||||||
|
|
||||||
|
if (stepState) {
|
||||||
return (
|
return (
|
||||||
<GuideStep
|
<GuideStep
|
||||||
accordionId={accordionId}
|
accordionId={accordionId}
|
||||||
stepStatus={stepStatus}
|
stepStatus={stepState.status}
|
||||||
stepConfig={step}
|
stepConfig={step}
|
||||||
stepNumber={index + 1}
|
stepNumber={index + 1}
|
||||||
navigateToStep={navigateToStep}
|
navigateToStep={navigateToStep}
|
||||||
key={accordionId}
|
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>
|
||||||
|
|
||||||
|
|
|
@ -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,13 +79,20 @@ 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
|
||||||
|
data-test-subj="activeStepButtonLabel"
|
||||||
|
>
|
||||||
|
{stepStatus === 'active'
|
||||||
|
? i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
|
||||||
defaultMessage: 'Start',
|
defaultMessage: 'Start',
|
||||||
|
})
|
||||||
|
: i18n.translate('guidedOnboarding.dropdownPanel.continueStepButtonLabel', {
|
||||||
|
defaultMessage: 'Continue',
|
||||||
})}
|
})}
|
||||||
</EuiButton>
|
</EuiButton>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
|
|
|
@ -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';
|
|
@ -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',
|
|
@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
|
@ -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',
|
|
@ -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';
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.fetchActiveGuideState$());
|
||||||
|
expect(state).toEqual(mockActiveSearchGuideState);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = await firstValueFrom(apiService.fetchGuideState$());
|
describe('fetchAllGuidesState', () => {
|
||||||
expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep });
|
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);
|
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);
|
expect(httpClient.put).toHaveBeenCalledTimes(1);
|
||||||
// this assertion depends on the guides config, we are checking for the last step
|
|
||||||
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
|
|
||||||
body: JSON.stringify({
|
|
||||||
activeGuide: searchGuide,
|
|
||||||
activeStep: 'completed',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`does nothing if the step is not active`, async () => {
|
|
||||||
await apiService.completeGuideStep(searchGuide, secondStep);
|
|
||||||
expect(httpClient.put).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
const currentStepIndex = guideState.steps.findIndex((step) => step.id === stepId);
|
||||||
activeGuide: guideID as UseCase,
|
const currentStep = guideState.steps[currentStepIndex];
|
||||||
activeStep: nextStepID,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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';
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue