From 059fecd3115cf4dad81c969d34b642359d47e82f Mon Sep 17 00:00:00 2001
From: Alison Goryachev
Date: Mon, 3 Oct 2022 15:46:07 -0400
Subject: [PATCH] [Guided onboarding] State management improvements (#141278)
---
.../public/components/app.tsx | 4 +
.../public/components/main.tsx | 189 ++++++++----
.../public/components/step_three.tsx | 90 ++++++
.../public/components/step_two.tsx | 4 +-
...grations_state_action_machine.test.ts.snap | 30 ++
.../src/core/unused_types.ts | 2 +
.../src/initial_state.test.ts | 5 +
.../migrations/type_registrations.test.ts | 1 +
.../common/{index.ts => constants.ts} | 0
src/plugins/guided_onboarding/common/types.ts | 44 +++
.../public/components/guide_panel.test.tsx | 204 ++++++++++--
.../public/components/guide_panel.tsx | 182 ++++++-----
.../public/components/guide_panel_step.tsx | 26 +-
.../index.ts} | 2 +-
.../{ => guides_config}/observability.ts | 2 +-
.../constants/{ => guides_config}/search.ts | 6 +-
.../constants/{ => guides_config}/security.ts | 2 +-
src/plugins/guided_onboarding/public/index.ts | 11 +-
.../guided_onboarding/public/plugin.tsx | 2 +-
.../public/services/api.test.ts | 292 +++++++++++++++---
.../guided_onboarding/public/services/api.ts | 284 ++++++++++++++---
.../public/services/helpers.test.ts | 20 +-
.../public/services/helpers.ts | 13 +-
src/plugins/guided_onboarding/public/types.ts | 20 +-
.../guided_onboarding/server/routes/index.ts | 152 ++++++---
.../server/saved_objects/guided_setup.ts | 14 +-
.../server/saved_objects/index.ts | 7 +-
27 files changed, 1225 insertions(+), 383 deletions(-)
create mode 100644 examples/guided_onboarding_example/public/components/step_three.tsx
rename src/plugins/guided_onboarding/common/{index.ts => constants.ts} (100%)
create mode 100644 src/plugins/guided_onboarding/common/types.ts
rename src/plugins/guided_onboarding/public/constants/{guides_config.ts => guides_config/index.ts} (92%)
rename src/plugins/guided_onboarding/public/constants/{ => guides_config}/observability.ts (97%)
rename src/plugins/guided_onboarding/public/constants/{ => guides_config}/search.ts (93%)
rename src/plugins/guided_onboarding/public/constants/{ => guides_config}/security.ts (97%)
diff --git a/examples/guided_onboarding_example/public/components/app.tsx b/examples/guided_onboarding_example/public/components/app.tsx
index dc8cbbdcfac8..a5252920c27f 100755
--- a/examples/guided_onboarding_example/public/components/app.tsx
+++ b/examples/guided_onboarding_example/public/components/app.tsx
@@ -23,6 +23,7 @@ import { CoreStart, ScopedHistory } from '@kbn/core/public';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types';
import { StepTwo } from './step_two';
import { StepOne } from './step_one';
+import { StepThree } from './step_three';
import { Main } from './main';
interface GuidedOnboardingExampleAppDeps {
@@ -60,6 +61,9 @@ export const GuidedOnboardingExampleApp = (props: GuidedOnboardingExampleAppDeps
+
+
+
diff --git a/examples/guided_onboarding_example/public/components/main.tsx b/examples/guided_onboarding_example/public/components/main.tsx
index 157b13f1276c..59e6fa319240 100644
--- a/examples/guided_onboarding_example/public/components/main.tsx
+++ b/examples/guided_onboarding_example/public/components/main.tsx
@@ -25,45 +25,50 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
-import {
+import type {
GuidedOnboardingPluginStart,
- GuidedOnboardingState,
- UseCase,
+ GuideState,
+ GuideStepIds,
+ GuideId,
+ GuideStep,
} from '@kbn/guided-onboarding-plugin/public';
+import { guidesConfig } from '@kbn/guided-onboarding-plugin/public';
interface MainProps {
guidedOnboarding: GuidedOnboardingPluginStart;
notifications: CoreStart['notifications'];
}
+
export const Main = (props: MainProps) => {
const {
guidedOnboarding: { guidedOnboardingApi },
notifications,
} = props;
const history = useHistory();
- const [guideState, setGuideState] = useState(undefined);
+ const [guidesState, setGuidesState] = useState(undefined);
+ const [activeGuide, setActiveGuide] = useState(undefined);
- const [selectedGuide, setSelectedGuide] = useState<
- GuidedOnboardingState['activeGuide'] | undefined
- >(undefined);
- const [selectedStep, setSelectedStep] = useState(
- undefined
- );
+ const [selectedGuide, setSelectedGuide] = useState(undefined);
+ const [selectedStep, setSelectedStep] = useState(undefined);
useEffect(() => {
- const subscription = guidedOnboardingApi
- ?.fetchGuideState$()
- .subscribe((newState: GuidedOnboardingState) => {
- setGuideState(newState);
- });
- return () => subscription?.unsubscribe();
+ const fetchGuidesState = async () => {
+ const newGuidesState = await guidedOnboardingApi?.fetchAllGuidesState();
+ setGuidesState(newGuidesState ? newGuidesState.state : []);
+ };
+
+ fetchGuidesState();
}, [guidedOnboardingApi]);
- const startGuide = async (guide: UseCase) => {
- const response = await guidedOnboardingApi?.updateGuideState({
- activeGuide: guide,
- activeStep: 'add_data',
- });
+ useEffect(() => {
+ const newActiveGuide = guidesState?.find((guide) => guide.isActive === true);
+ if (newActiveGuide) {
+ setActiveGuide(newActiveGuide);
+ }
+ }, [guidesState, setActiveGuide]);
+
+ const activateGuide = async (guideId: GuideId, guideState?: GuideState) => {
+ const response = await guidedOnboardingApi?.activateGuide(guideId, guideState);
if (response) {
notifications.toasts.addSuccess(
@@ -75,11 +80,45 @@ export const Main = (props: MainProps) => {
};
const updateGuideState = async () => {
- const response = await guidedOnboardingApi?.updateGuideState({
- activeGuide: selectedGuide!,
- activeStep: selectedStep!,
+ const selectedGuideConfig = guidesConfig[selectedGuide!];
+ const selectedStepIndex = selectedGuideConfig.steps.findIndex(
+ (step) => step.id === selectedStep!
+ );
+
+ // Noop if the selected step is invalid
+ if (selectedStepIndex === -1) {
+ return;
+ }
+
+ const updatedSteps: GuideStep[] = selectedGuideConfig.steps.map((step, stepIndex) => {
+ if (selectedStepIndex > stepIndex) {
+ return {
+ id: step.id,
+ status: 'complete',
+ };
+ }
+
+ if (selectedStepIndex < stepIndex) {
+ return {
+ id: step.id,
+ status: 'inactive',
+ };
+ }
+
+ return {
+ id: step.id,
+ status: 'active',
+ };
});
+ const updatedGuideState: GuideState = {
+ isActive: true,
+ status: 'in_progress',
+ steps: updatedSteps,
+ guideId: selectedGuide!,
+ };
+
+ const response = await guidedOnboardingApi?.updateGuideState(updatedGuideState, true);
if (response) {
notifications.toasts.addSuccess(
i18n.translate('guidedOnboardingExample.updateGuideState.toastLabel', {
@@ -116,7 +155,7 @@ export const Main = (props: MainProps) => {
so there is no need to 'load' the state from the server."
/>
- {guideState ? (
+ {activeGuide ? (
-
{
defaultMessage="Active guide"
/>
- - {guideState.activeGuide ?? 'undefined'}
+ - {activeGuide.guideId}
-
- - {guideState.activeStep ?? 'undefined'}
+ -
+ {activeGuide.steps.map((step) => {
+ return (
+ <>
+ {`Step "${step.id}": ${step.status}`}
+ >
+ );
+ })}
+
- ) : undefined}
+ ) : (
+
+
+
+ )}
-
- startGuide('search')} fill>
-
-
-
-
- startGuide('observability')} fill>
-
-
-
-
- startGuide('security')} fill>
-
-
-
+ {(Object.keys(guidesConfig) as GuideId[]).map((guideId) => {
+ const guideState = guidesState?.find((guide) => guide.guideId === guideId);
+ return (
+
+ activateGuide(guideId, guideState)}
+ fill
+ disabled={guideState?.status === 'complete'}
+ >
+ {guideState === undefined && (
+
+ )}
+ {(guideState?.isActive === true ||
+ guideState?.status === 'in_progress' ||
+ guideState?.status === 'ready_to_complete') && (
+
+ )}
+ {guideState?.status === 'complete' && (
+
+ )}
+
+
+ );
+ })}
@@ -187,16 +259,15 @@ export const Main = (props: MainProps) => {
{
- const value = e.target.value as UseCase;
+ const value = e.target.value as GuideId;
const shouldResetState = value.trim().length === 0;
if (shouldResetState) {
setSelectedGuide(undefined);
@@ -209,10 +280,10 @@ export const Main = (props: MainProps) => {
-
+
setSelectedStep(e.target.value)}
+ onChange={(e) => setSelectedStep(e.target.value as GuideStepIds)}
/>
diff --git a/examples/guided_onboarding_example/public/components/step_three.tsx b/examples/guided_onboarding_example/public/components/step_three.tsx
new file mode 100644
index 000000000000..ffe9d8799361
--- /dev/null
+++ b/examples/guided_onboarding_example/public/components/step_three.tsx
@@ -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(false);
+
+ useEffect(() => {
+ const subscription = guidedOnboardingApi
+ ?.isGuideStepActive$('search', 'search_experience')
+ .subscribe((isStepActive) => {
+ setIsTourStepOpen(isStepActive);
+ });
+ return () => subscription?.unsubscribe();
+ }, [guidedOnboardingApi]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Click this button to complete step 3.
+
+ }
+ isStepOpen={isTourStepOpen}
+ minWidth={300}
+ onFinish={() => {
+ setIsTourStepOpen(false);
+ }}
+ step={1}
+ stepsTotal={1}
+ title="Step Build search experience"
+ anchorPosition="rightUp"
+ >
+ {
+ await guidedOnboardingApi?.completeGuideStep('search', 'search_experience');
+ }}
+ >
+ Complete step 3
+
+
+
+ >
+ );
+};
diff --git a/examples/guided_onboarding_example/public/components/step_two.tsx b/examples/guided_onboarding_example/public/components/step_two.tsx
index a79ce2329351..07f4fd7e63e0 100644
--- a/examples/guided_onboarding_example/public/components/step_two.tsx
+++ b/examples/guided_onboarding_example/public/components/step_two.tsx
@@ -55,7 +55,7 @@ export const StepTwo = (props: StepTwoProps) => {
@@ -73,7 +73,7 @@ export const StepTwo = (props: StepTwoProps) => {
}}
step={1}
stepsTotal={1}
- title="Step Search experience"
+ title="Step Browse documents"
anchorPosition="rightUp"
>
{
"type": "fleet-enrollment-api-keys",
},
},
+ Object {
+ "term": Object {
+ "type": "guided-setup-state",
+ },
+ },
Object {
"term": Object {
"type": "ml-telemetry",
diff --git a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts
index a1f749016834..4fd5ca5cd2ae 100644
--- a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts
+++ b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts
@@ -60,6 +60,7 @@ const previouslyRegisteredTypes = [
'fleet-preconfiguration-deletion-record',
'graph-workspace',
'guided-setup-state',
+ 'guided-onboarding-guide-state',
'index-pattern',
'infrastructure-monitoring-log-view',
'infrastructure-ui-source',
diff --git a/src/plugins/guided_onboarding/common/index.ts b/src/plugins/guided_onboarding/common/constants.ts
similarity index 100%
rename from src/plugins/guided_onboarding/common/index.ts
rename to src/plugins/guided_onboarding/common/constants.ts
diff --git a/src/plugins/guided_onboarding/common/types.ts b/src/plugins/guided_onboarding/common/types.ts
new file mode 100644
index 000000000000..412154ede98b
--- /dev/null
+++ b/src/plugins/guided_onboarding/common/types.ts
@@ -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[];
+}
diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx
index 5eaf24163d2a..3506c15fcba3 100644
--- a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx
+++ b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx
@@ -13,18 +13,39 @@ import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { HttpSetup } from '@kbn/core/public';
-import { apiService } from '../services/api';
import { guidesConfig } from '../constants/guides_config';
+import type { GuideState } from '../../common/types';
+import { apiService } from '../services/api';
import { GuidePanel } from './guide_panel';
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
const applicationMock = applicationServiceMock.createStartContract();
+const mockActiveSearchGuideState: GuideState = {
+ guideId: 'search',
+ isActive: true,
+ status: 'in_progress',
+ steps: [
+ {
+ id: 'add_data',
+ status: 'active',
+ },
+ {
+ id: 'browse_docs',
+ status: 'inactive',
+ },
+ {
+ id: 'search_experience',
+ status: 'inactive',
+ },
+ ],
+};
+
const getGuidePanel = () => () => {
return ;
};
-describe('GuidePanel', () => {
+describe('Guided setup', () => {
let httpClient: jest.Mocked;
let testBed: TestBed;
@@ -32,7 +53,7 @@ describe('GuidePanel', () => {
httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' });
// Set default state on initial request (no active guides)
httpClient.get.mockResolvedValue({
- state: { activeGuide: 'unset', activeStep: 'unset' },
+ state: [],
});
apiService.setup(httpClient);
@@ -48,29 +69,164 @@ describe('GuidePanel', () => {
jest.restoreAllMocks();
});
- test('it should be disabled in there is no active guide', async () => {
- const { exists } = testBed;
- expect(exists('disabledGuideButton')).toBe(true);
- expect(exists('guideButton')).toBe(false);
- expect(exists('guidePanel')).toBe(false);
- });
-
- test('it should be enabled if there is an active guide', async () => {
- const { exists, component, find } = testBed;
-
- await act(async () => {
- // Enable the "search" guide
- await apiService.updateGuideState({
- activeGuide: 'search',
- activeStep: guidesConfig.search.steps[0].id,
- });
+ describe('Button component', () => {
+ test('should be disabled in there is no active guide', async () => {
+ const { exists } = testBed;
+ expect(exists('disabledGuideButton')).toBe(true);
+ expect(exists('guideButton')).toBe(false);
+ expect(exists('guidePanel')).toBe(false);
});
- component.update();
+ test('should be enabled if there is an active guide', async () => {
+ const { exists, component, find } = testBed;
- expect(exists('disabledGuideButton')).toBe(false);
- expect(exists('guideButton')).toBe(true);
- expect(exists('guidePanel')).toBe(true);
- expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length);
+ await act(async () => {
+ // Enable the "search" guide
+ await apiService.updateGuideState(mockActiveSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(exists('disabledGuideButton')).toBe(false);
+ expect(exists('guideButton')).toBe(true);
+ expect(find('guideButton').text()).toEqual('Setup guide');
+ });
+
+ test('should show the step number in the button label if a step is active', async () => {
+ const { component, find } = testBed;
+
+ const mockInProgressSearchGuideState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress',
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ };
+
+ await act(async () => {
+ await apiService.updateGuideState(mockInProgressSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(find('guideButton').text()).toEqual('Setup guide: step 1');
+ });
+ });
+
+ describe('Panel component', () => {
+ test('should be enabled if a guide is activated', async () => {
+ const { exists, component, find } = testBed;
+
+ await act(async () => {
+ // Enable the "search" guide
+ await apiService.updateGuideState(mockActiveSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(exists('guidePanel')).toBe(true);
+ expect(exists('guideProgress')).toBe(false);
+ expect(find('guidePanelStep').length).toEqual(guidesConfig.search.steps.length);
+ });
+
+ test('should show the progress bar if the first step has been completed', async () => {
+ const { component, exists } = testBed;
+
+ const mockInProgressSearchGuideState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'complete',
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ };
+
+ await act(async () => {
+ await apiService.updateGuideState(mockInProgressSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(exists('guidePanel')).toBe(true);
+ expect(exists('guideProgress')).toBe(true);
+ });
+
+ test('should show the "Continue using Elastic" button when all steps has been completed', async () => {
+ const { component, exists } = testBed;
+
+ const readyToCompleteGuideState: GuideState = {
+ guideId: 'search',
+ status: 'ready_to_complete',
+ isActive: true,
+ steps: [
+ {
+ id: 'add_data',
+ status: 'complete',
+ },
+ {
+ id: 'browse_docs',
+ status: 'complete',
+ },
+ {
+ id: 'search_experience',
+ status: 'complete',
+ },
+ ],
+ };
+
+ await act(async () => {
+ await apiService.updateGuideState(readyToCompleteGuideState, true);
+ });
+
+ component.update();
+
+ expect(exists('useElasticButton')).toBe(true);
+ });
+
+ describe('Steps', () => {
+ test('should show "Start" button label if step has not been started', async () => {
+ const { component, find } = testBed;
+
+ await act(async () => {
+ // Enable the "search" guide
+ await apiService.updateGuideState(mockActiveSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(find('activeStepButtonLabel').text()).toEqual('Start');
+ });
+
+ test('should show "Continue" button label if step is in progress', async () => {
+ const { component, find } = testBed;
+
+ const mockInProgressSearchGuideState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress',
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ };
+
+ await act(async () => {
+ await apiService.updateGuideState(mockInProgressSearchGuideState, true);
+ });
+
+ component.update();
+
+ expect(find('activeStepButtonLabel').text()).toEqual('Continue');
+ });
+ });
});
});
diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.tsx
index f32f55e42b34..bf57d502918d 100644
--- a/src/plugins/guided_onboarding/public/components/guide_panel.tsx
+++ b/src/plugins/guided_onboarding/public/components/guide_panel.tsx
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
@@ -30,7 +30,9 @@ import { ApplicationStart } from '@kbn/core-application-browser';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { guidesConfig } from '../constants/guides_config';
-import type { GuideConfig, StepStatus, GuidedOnboardingState, StepConfig } from '../types';
+import type { GuideState, GuideStepIds } from '../../common/types';
+import type { GuideConfig, StepConfig } from '../types';
+
import type { ApiService } from '../services/api';
import { GuideStep } from './guide_panel_step';
@@ -41,47 +43,48 @@ interface GuidePanelProps {
application: ApplicationStart;
}
-const getConfig = (state?: GuidedOnboardingState): GuideConfig | undefined => {
- if (state?.activeGuide && state.activeGuide !== 'unset') {
- return guidesConfig[state.activeGuide];
+const getConfig = (state?: GuideState): GuideConfig | undefined => {
+ if (state) {
+ return guidesConfig[state.guideId];
}
return undefined;
};
-const getCurrentStep = (
- steps?: StepConfig[],
- state?: GuidedOnboardingState
-): number | undefined => {
- if (steps && state?.activeStep) {
- const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === state.activeStep);
- if (activeStepIndex > -1) {
- return activeStepIndex + 1;
+const getStepNumber = (state?: GuideState): number | undefined => {
+ let stepNumber: number | undefined;
+
+ state?.steps.forEach((step, stepIndex) => {
+ // If the step is in_progress, show that step number
+ if (step.status === 'in_progress') {
+ stepNumber = stepIndex + 1;
}
- return undefined;
- }
+ // If the step is active, show the previous step number
+ if (step.status === 'active') {
+ stepNumber = stepIndex;
+ }
+ });
+
+ return stepNumber;
};
-const getStepStatus = (steps: StepConfig[], stepIndex: number, activeStep?: string): StepStatus => {
- const activeStepIndex = steps.findIndex((step: StepConfig) => step.id === activeStep);
-
- if (activeStepIndex < stepIndex) {
- return 'incomplete';
+const getProgress = (state?: GuideState): number => {
+ if (state) {
+ return state.steps.reduce((acc, currentVal) => {
+ if (currentVal.status === 'complete') {
+ acc = acc + 1;
+ }
+ return acc;
+ }, 0);
}
-
- if (activeStepIndex === stepIndex) {
- return 'in_progress';
- }
-
- return 'complete';
+ return 0;
};
export const GuidePanel = ({ api, application }: GuidePanelProps) => {
const { euiTheme } = useEuiTheme();
const [isGuideOpen, setIsGuideOpen] = useState(false);
- const [guideState, setGuideState] = useState(undefined);
- const isFirstRender = useRef(true);
+ const [guideState, setGuideState] = useState(undefined);
const styles = getGuidePanelStyles(euiTheme);
@@ -89,10 +92,10 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen);
};
- const navigateToStep = (step: StepConfig) => {
- setIsGuideOpen(false);
- if (step.location) {
- application.navigateToApp(step.location.appID, { path: step.location.path });
+ const navigateToStep = async (stepId: GuideStepIds, stepLocation: StepConfig['location']) => {
+ await api.startGuideStep(guideState!.guideId, stepId);
+ if (stepLocation) {
+ application.navigateToApp(stepLocation.appID, { path: stepLocation.path });
}
};
@@ -101,22 +104,25 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
application.navigateToApp('home', { path: '#getting_started' });
};
+ const completeGuide = async () => {
+ await api.completeGuide(guideState!.guideId);
+ };
+
useEffect(() => {
- const subscription = api.fetchGuideState$().subscribe((newState) => {
- if (
- guideState?.activeGuide !== newState.activeGuide ||
- guideState?.activeStep !== newState.activeStep
- ) {
- if (isFirstRender.current) {
- isFirstRender.current = false;
- } else {
- setIsGuideOpen(true);
- }
+ const subscription = api.fetchActiveGuideState$().subscribe((newGuideState) => {
+ if (newGuideState) {
+ setGuideState(newGuideState);
}
- setGuideState(newState);
});
return () => subscription.unsubscribe();
- }, [api, guideState?.activeGuide, guideState?.activeStep]);
+ }, [api]);
+
+ useEffect(() => {
+ const subscription = api.isGuidePanelOpen$.subscribe((isGuidePanelOpen) => {
+ setIsGuideOpen(isGuidePanelOpen);
+ });
+ return () => subscription.unsubscribe();
+ }, [api]);
const guideConfig = getConfig(guideState);
@@ -139,16 +145,17 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
);
}
- const currentStep = getCurrentStep(guideConfig.steps, guideState);
+ const stepNumber = getStepNumber(guideState);
+ const stepsCompleted = getProgress(guideState);
return (
<>
- {currentStep
+ {Boolean(stepNumber)
? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', {
- defaultMessage: 'Setup guide: Step {currentStep}',
+ defaultMessage: 'Setup guide: step {stepNumber}',
values: {
- currentStep,
+ stepNumber,
},
})
: i18n.translate('guidedOnboarding.guidedSetupButtonLabel', {
@@ -203,46 +210,61 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
>
)}
-
+ {/* Progress bar should only show after the first step has been complete */}
+ {stepsCompleted > 0 && (
+ <>
+
+
- {/*
- 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
- */}
-
-
-
+
+ >
+ )}
{guideConfig?.steps.map((step, index, steps) => {
const accordionId = htmlIdGenerator(`accordion${index}`)();
- const stepStatus = getStepStatus(steps, index, guideState?.activeStep);
+ const stepState = guideState?.steps[index];
- return (
-
- );
+ if (stepState) {
+ return (
+
+ );
+ }
})}
+
+ {guideState?.status === 'ready_to_complete' && (
+
+
+
+ {i18n.translate('guidedOnboarding.dropdownPanel.elasticButtonLabel', {
+ defaultMessage: 'Continue using Elastic',
+ })}
+
+
+
+ )}
diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx
index e6a300b6b674..8a98d87debf1 100644
--- a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx
+++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx
@@ -20,7 +20,8 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import type { StepStatus, StepConfig } from '../types';
+import type { StepStatus, GuideStepIds } from '../../common/types';
+import type { StepConfig } from '../types';
import { getGuidePanelStepStyles } from './guide_panel_step.styles';
interface GuideStepProps {
@@ -28,7 +29,7 @@ interface GuideStepProps {
stepStatus: StepStatus;
stepConfig: StepConfig;
stepNumber: number;
- navigateToStep: (step: StepConfig) => void;
+ navigateToStep: (stepId: GuideStepIds, stepLocation: StepConfig['location']) => void;
}
export const GuideStep = ({
@@ -64,7 +65,7 @@ export const GuideStep = ({
id={accordionId}
buttonContent={buttonContent}
arrowDisplay="right"
- forceState={stepStatus === 'in_progress' ? 'open' : 'closed'}
+ forceState={stepStatus === 'in_progress' || stepStatus === 'active' ? 'open' : 'closed'}
>
<>
@@ -78,14 +79,21 @@ export const GuideStep = ({
- {stepStatus === 'in_progress' && (
+ {(stepStatus === 'in_progress' || stepStatus === 'active') && (
- navigateToStep(stepConfig)} fill>
- {/* TODO: Support for conditional "Continue" button label if user revists a step - https://github.com/elastic/kibana/issues/139752 */}
- {i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
- defaultMessage: 'Start',
- })}
+ navigateToStep(stepConfig.id, stepConfig.location)}
+ fill
+ data-test-subj="activeStepButtonLabel"
+ >
+ {stepStatus === 'active'
+ ? i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
+ defaultMessage: 'Start',
+ })
+ : i18n.translate('guidedOnboarding.dropdownPanel.continueStepButtonLabel', {
+ defaultMessage: 'Continue',
+ })}
diff --git a/src/plugins/guided_onboarding/public/constants/guides_config.ts b/src/plugins/guided_onboarding/public/constants/guides_config/index.ts
similarity index 92%
rename from src/plugins/guided_onboarding/public/constants/guides_config.ts
rename to src/plugins/guided_onboarding/public/constants/guides_config/index.ts
index 0cbee9d4b12b..9ce81cf9d469 100644
--- a/src/plugins/guided_onboarding/public/constants/guides_config.ts
+++ b/src/plugins/guided_onboarding/public/constants/guides_config/index.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { GuidesConfig } from '../types';
+import type { GuidesConfig } from '../../types';
import { securityConfig } from './security';
import { observabilityConfig } from './observability';
import { searchConfig } from './search';
diff --git a/src/plugins/guided_onboarding/public/constants/observability.ts b/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts
similarity index 97%
rename from src/plugins/guided_onboarding/public/constants/observability.ts
rename to src/plugins/guided_onboarding/public/constants/guides_config/observability.ts
index 3f96ad126817..91b69490131b 100644
--- a/src/plugins/guided_onboarding/public/constants/observability.ts
+++ b/src/plugins/guided_onboarding/public/constants/guides_config/observability.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import type { GuideConfig } from '../types';
+import type { GuideConfig } from '../../types';
export const observabilityConfig: GuideConfig = {
title: 'Observe my infrastructure',
diff --git a/src/plugins/guided_onboarding/public/constants/search.ts b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts
similarity index 93%
rename from src/plugins/guided_onboarding/public/constants/search.ts
rename to src/plugins/guided_onboarding/public/constants/guides_config/search.ts
index 1f2a26b5f0b9..57d81fdfe130 100644
--- a/src/plugins/guided_onboarding/public/constants/search.ts
+++ b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import type { GuideConfig } from '../types';
+import type { GuideConfig } from '../../types';
export const searchConfig: GuideConfig = {
title: 'Search my data',
@@ -50,6 +50,10 @@ export const searchConfig: GuideConfig = {
'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.',
'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.',
],
+ location: {
+ appID: 'guidedOnboardingExample',
+ path: 'stepThree',
+ },
},
],
};
diff --git a/src/plugins/guided_onboarding/public/constants/security.ts b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts
similarity index 97%
rename from src/plugins/guided_onboarding/public/constants/security.ts
rename to src/plugins/guided_onboarding/public/constants/guides_config/security.ts
index 2c19e7acc2be..df17d00d7f2d 100644
--- a/src/plugins/guided_onboarding/public/constants/security.ts
+++ b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import type { GuideConfig } from '../types';
+import type { GuideConfig } from '../../types';
export const securityConfig: GuideConfig = {
title: 'Get started with SIEM',
diff --git a/src/plugins/guided_onboarding/public/index.ts b/src/plugins/guided_onboarding/public/index.ts
index 5b950b190c37..08ae777bb360 100755
--- a/src/plugins/guided_onboarding/public/index.ts
+++ b/src/plugins/guided_onboarding/public/index.ts
@@ -12,9 +12,8 @@ import { GuidedOnboardingPlugin } from './plugin';
export function plugin(ctx: PluginInitializerContext) {
return new GuidedOnboardingPlugin(ctx);
}
-export type {
- GuidedOnboardingPluginSetup,
- GuidedOnboardingPluginStart,
- GuidedOnboardingState,
- UseCase,
-} from './types';
+export type { GuidedOnboardingPluginSetup, GuidedOnboardingPluginStart } from './types';
+
+export type { GuideId, GuideStepIds, GuideState, GuideStep } from '../common/types';
+
+export { guidesConfig } from './constants/guides_config';
diff --git a/src/plugins/guided_onboarding/public/plugin.tsx b/src/plugins/guided_onboarding/public/plugin.tsx
index 902acaa899e3..f74e19a03300 100755
--- a/src/plugins/guided_onboarding/public/plugin.tsx
+++ b/src/plugins/guided_onboarding/public/plugin.tsx
@@ -20,7 +20,7 @@ import {
} from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
-import {
+import type {
ClientConfigType,
GuidedOnboardingPluginSetup,
GuidedOnboardingPluginStart,
diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts
index 9f5e20cb9f89..ffe5596bd7e3 100644
--- a/src/plugins/guided_onboarding/public/services/api.test.ts
+++ b/src/plugins/guided_onboarding/public/services/api.test.ts
@@ -10,15 +10,33 @@ import { HttpSetup } from '@kbn/core/public';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { firstValueFrom, Subscription } from 'rxjs';
-import { API_BASE_PATH } from '../../common';
-import { ApiService } from './api';
-import { GuidedOnboardingState } from '..';
+import { API_BASE_PATH } from '../../common/constants';
import { guidesConfig } from '../constants/guides_config';
+import type { GuideState } from '../../common/types';
+import { ApiService } from './api';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
-const secondStep = guidesConfig[searchGuide].steps[1].id;
-const lastStep = guidesConfig[searchGuide].steps[2].id;
+
+const mockActiveSearchGuideState: GuideState = {
+ guideId: searchGuide,
+ isActive: true,
+ status: 'in_progress',
+ steps: [
+ {
+ id: 'add_data',
+ status: 'active',
+ },
+ {
+ id: 'browse_docs',
+ status: 'inactive',
+ },
+ {
+ id: 'search_experience',
+ status: 'inactive',
+ },
+ ],
+};
describe('GuidedOnboarding ApiService', () => {
let httpClient: jest.Mocked;
@@ -41,40 +59,67 @@ describe('GuidedOnboarding ApiService', () => {
jest.restoreAllMocks();
});
- describe('fetchGuideState$', () => {
+ describe('fetchActiveGuideState$', () => {
it('sends a request to the get API', () => {
- subscription = apiService.fetchGuideState$().subscribe();
+ subscription = apiService.fetchActiveGuideState$().subscribe();
expect(httpClient.get).toHaveBeenCalledTimes(1);
- expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`);
+ expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+ query: { active: true },
+ });
});
it('broadcasts the updated state', async () => {
- await apiService.updateGuideState({
- activeGuide: searchGuide,
- activeStep: secondStep,
- });
+ await apiService.activateGuide(searchGuide);
- const state = await firstValueFrom(apiService.fetchGuideState$());
- expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep });
+ const state = await firstValueFrom(apiService.fetchActiveGuideState$());
+ expect(state).toEqual(mockActiveSearchGuideState);
+ });
+ });
+
+ describe('fetchAllGuidesState', () => {
+ it('sends a request to the get API', async () => {
+ await apiService.fetchAllGuidesState();
+ expect(httpClient.get).toHaveBeenCalledTimes(1);
+ expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`);
});
});
describe('updateGuideState', () => {
it('sends a request to the put API', async () => {
- const state = {
- activeGuide: searchGuide,
- activeStep: secondStep,
+ const updatedState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress', // update the first step status
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
};
- await apiService.updateGuideState(state as GuidedOnboardingState);
+ await apiService.updateGuideState(updatedState, false);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
- body: JSON.stringify(state),
+ body: JSON.stringify(updatedState),
});
});
});
describe('isGuideStepActive$', () => {
- it('returns true if the step is active', async (done) => {
+ it('returns true if the step has been started', async (done) => {
+ const updatedState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress',
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ };
+ await apiService.updateGuideState(updatedState, false);
+
subscription = apiService
.isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
@@ -84,9 +129,10 @@ describe('GuidedOnboarding ApiService', () => {
});
});
- it('returns false if the step is not active', async (done) => {
+ it('returns false if the step is not been started', async (done) => {
+ await apiService.updateGuideState(mockActiveSearchGuideState, false);
subscription = apiService
- .isGuideStepActive$(searchGuide, secondStep)
+ .isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
if (!isStepActive) {
done();
@@ -95,40 +141,192 @@ describe('GuidedOnboarding ApiService', () => {
});
});
+ describe('activateGuide', () => {
+ it('activates a new guide', async () => {
+ await apiService.activateGuide(searchGuide);
+
+ expect(httpClient.put).toHaveBeenCalledTimes(1);
+ expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+ body: JSON.stringify({
+ isActive: true,
+ status: 'in_progress',
+ steps: [
+ {
+ id: 'add_data',
+ status: 'active',
+ },
+ {
+ id: 'browse_docs',
+ status: 'inactive',
+ },
+ {
+ id: 'search_experience',
+ status: 'inactive',
+ },
+ ],
+ guideId: searchGuide,
+ }),
+ });
+ });
+
+ it('reactivates a guide that has already been started', async () => {
+ await apiService.activateGuide(searchGuide, mockActiveSearchGuideState);
+
+ expect(httpClient.put).toHaveBeenCalledTimes(1);
+ expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+ body: JSON.stringify({
+ ...mockActiveSearchGuideState,
+ isActive: true,
+ }),
+ });
+ });
+ });
+
+ describe('completeGuide', () => {
+ const readyToCompleteGuideState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: 'add_data',
+ status: 'complete',
+ },
+ {
+ id: 'browse_docs',
+ status: 'complete',
+ },
+ {
+ id: 'search_experience',
+ status: 'complete',
+ },
+ ],
+ };
+
+ beforeEach(async () => {
+ await apiService.updateGuideState(readyToCompleteGuideState, false);
+ });
+
+ it('updates the selected guide and marks it as complete', async () => {
+ await apiService.completeGuide(searchGuide);
+
+ expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+ body: JSON.stringify({
+ ...readyToCompleteGuideState,
+ isActive: false,
+ status: 'complete',
+ }),
+ });
+ });
+
+ it('returns undefined if the selected guide is not active', async () => {
+ const completedState = await apiService.completeGuide('observability'); // not active
+ expect(completedState).not.toBeDefined();
+ });
+
+ it('returns undefined if the selected guide has uncompleted steps', async () => {
+ const incompleteGuideState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: 'add_data',
+ status: 'complete',
+ },
+ {
+ id: 'browse_docs',
+ status: 'complete',
+ },
+ {
+ id: 'search_experience',
+ status: 'in_progress',
+ },
+ ],
+ };
+ await apiService.updateGuideState(incompleteGuideState, false);
+
+ const completedState = await apiService.completeGuide(searchGuide);
+ expect(completedState).not.toBeDefined();
+ });
+ });
+
+ describe('startGuideStep', () => {
+ beforeEach(async () => {
+ await apiService.updateGuideState(mockActiveSearchGuideState, false);
+ });
+
+ it('updates the selected step and marks it as in_progress', async () => {
+ await apiService.startGuideStep(searchGuide, firstStep);
+
+ expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+ body: JSON.stringify({
+ ...mockActiveSearchGuideState,
+ isActive: true,
+ status: 'in_progress',
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress',
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ }),
+ });
+ });
+
+ it('returns undefined if the selected guide is not active', async () => {
+ const startState = await apiService.startGuideStep('observability', 'add_data'); // not active
+ expect(startState).not.toBeDefined();
+ });
+ });
+
describe('completeGuideStep', () => {
- it(`completes the step when it's active`, async () => {
+ it(`completes the step when it's in progress`, async () => {
+ const updatedState: GuideState = {
+ ...mockActiveSearchGuideState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'in_progress', // Mark a step as in_progress in order to test the "completeGuideStep" behavior
+ },
+ mockActiveSearchGuideState.steps[1],
+ mockActiveSearchGuideState.steps[2],
+ ],
+ };
+ await apiService.updateGuideState(updatedState, false);
+
await apiService.completeGuideStep(searchGuide, firstStep);
- expect(httpClient.put).toHaveBeenCalledTimes(1);
- // this assertion depends on the guides config, we are checking for the next step
- expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
+
+ // Once on update, once on complete
+ expect(httpClient.put).toHaveBeenCalledTimes(2);
+ // Verify the completed step now has a "complete" status, and the subsequent step is "active"
+ expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
- activeGuide: searchGuide,
- activeStep: secondStep,
+ ...updatedState,
+ steps: [
+ {
+ id: mockActiveSearchGuideState.steps[0].id,
+ status: 'complete',
+ },
+ {
+ id: mockActiveSearchGuideState.steps[1].id,
+ status: 'active',
+ },
+ mockActiveSearchGuideState.steps[2],
+ ],
}),
});
});
- it(`completes the guide when the last step is active`, async () => {
- httpClient.get.mockResolvedValue({
- // this state depends on the guides config
- state: { activeGuide: searchGuide, activeStep: lastStep },
- });
- apiService.setup(httpClient);
-
- await apiService.completeGuideStep(searchGuide, lastStep);
- expect(httpClient.put).toHaveBeenCalledTimes(1);
- // this assertion depends on the guides config, we are checking for the last step
- expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
- body: JSON.stringify({
- activeGuide: searchGuide,
- activeStep: 'completed',
- }),
- });
+ it('returns undefined if the selected guide is not active', async () => {
+ const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active
+ expect(startState).not.toBeDefined();
});
- it(`does nothing if the step is not active`, async () => {
- await apiService.completeGuideStep(searchGuide, secondStep);
- expect(httpClient.put).not.toHaveBeenCalled();
+ it('does nothing if the step is not in progress', async () => {
+ await apiService.updateGuideState(mockActiveSearchGuideState, false);
+
+ await apiService.completeGuideStep(searchGuide, firstStep);
+ // Expect only 1 call from updateGuideState()
+ expect(httpClient.put).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts
index b99975c3a837..1adfaa5d8cc2 100644
--- a/src/plugins/guided_onboarding/public/services/api.ts
+++ b/src/plugins/guided_onboarding/public/services/api.ts
@@ -9,31 +9,42 @@
import { HttpSetup } from '@kbn/core/public';
import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs';
-import { API_BASE_PATH } from '../../common';
-import { GuidedOnboardingState, UseCase } from '../types';
-import { getNextStep, isLastStep } from './helpers';
+import { API_BASE_PATH } from '../../common/constants';
+import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types';
+import { isLastStep, getGuideConfig } from './helpers';
export class ApiService {
private client: HttpSetup | undefined;
- private onboardingGuideState$!: BehaviorSubject;
+ private onboardingGuideState$!: BehaviorSubject;
+ public isGuidePanelOpen$: BehaviorSubject = new BehaviorSubject(false);
public setup(httpClient: HttpSetup): void {
this.client = httpClient;
- this.onboardingGuideState$ = new BehaviorSubject(undefined);
+ this.onboardingGuideState$ = new BehaviorSubject(undefined);
}
/**
- * An Observable with the guided onboarding state.
+ * An Observable with the active guide state.
* Initially the state is fetched from the backend.
* Subsequently, the observable is updated automatically, when the state changes.
*/
- public fetchGuideState$(): Observable {
+ public fetchActiveGuideState$(): Observable {
// TODO add error handling if this.client has not been initialized or request fails
return this.onboardingGuideState$.pipe(
concatMap((state) =>
state === undefined
- ? from(this.client!.get<{ state: GuidedOnboardingState }>(`${API_BASE_PATH}/state`)).pipe(
- map((response) => response.state)
+ ? from(
+ this.client!.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`, {
+ query: {
+ active: true,
+ },
+ })
+ ).pipe(
+ map((response) => {
+ // There should only be 1 active guide
+ const hasState = response.state.length === 1;
+ return hasState ? response.state[0] : undefined;
+ })
)
: of(state)
)
@@ -41,25 +52,45 @@ export class ApiService {
}
/**
- * Updates the state of the guided onboarding
- * @param {GuidedOnboardingState} newState the new state of the guided onboarding
- * @return {Promise} a promise with the updated state or undefined if the update fails
+ * Async operation to fetch state for all guides
+ * This is useful for the onboarding landing page,
+ * where all guides are displayed with their corresponding status
*/
- public async updateGuideState(
- newState: GuidedOnboardingState
- ): Promise<{ state: GuidedOnboardingState } | undefined> {
+ public async fetchAllGuidesState(): Promise<{ state: GuideState[] } | undefined> {
if (!this.client) {
throw new Error('ApiService has not be initialized.');
}
try {
- const response = await this.client.put<{ state: GuidedOnboardingState }>(
- `${API_BASE_PATH}/state`,
- {
- body: JSON.stringify(newState),
- }
- );
+ return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`);
+ } catch (error) {
+ // TODO handle error
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+ }
+
+ /**
+ * Updates the SO with the updated guide state and refreshes the observables
+ * This is largely used internally and for tests
+ * @param {GuideState} guideState the updated guide state
+ * @param {boolean} panelState boolean to determine whether the dropdown panel should open or not
+ * @return {Promise} a promise with the updated guide state
+ */
+ public async updateGuideState(
+ newState: GuideState,
+ panelState: boolean
+ ): Promise<{ state: GuideState } | undefined> {
+ if (!this.client) {
+ throw new Error('ApiService has not be initialized.');
+ }
+
+ try {
+ const response = await this.client.put<{ state: GuideState }>(`${API_BASE_PATH}/state`, {
+ body: JSON.stringify(newState),
+ });
this.onboardingGuideState$.next(newState);
+ this.isGuidePanelOpen$.next(panelState);
return response;
} catch (error) {
// TODO handle error
@@ -69,47 +100,204 @@ export class ApiService {
}
/**
- * An observable with the boolean value if the step is active.
- * Returns true, if the passed params identify the guide step that is currently active.
+ * Activates a guide by guideId
+ * This is useful for the onboarding landing page, when a user selects a guide to start or continue
+ * @param {GuideId} guideID the id of the guide (one of search, observability, security)
+ * @param {GuideState} guideState (optional) the selected guide state, if it exists (i.e., if a user is continuing a guide)
+ * @return {Promise} a promise with the updated guide state
+ */
+ public async activateGuide(
+ guideId: GuideId,
+ guide?: GuideState
+ ): Promise<{ state: GuideState } | undefined> {
+ // If we already have the guide state (i.e., user has already started the guide at some point),
+ // simply pass it through so they can continue where they left off, and update the guide to active
+ if (guide) {
+ return await this.updateGuideState(
+ {
+ ...guide,
+ isActive: true,
+ },
+ true
+ );
+ }
+
+ // If this is the 1st-time attempt, we need to create the default state
+ const guideConfig = getGuideConfig(guideId);
+
+ if (guideConfig) {
+ const updatedSteps: GuideStep[] = guideConfig.steps.map((step, stepIndex) => {
+ const isFirstStep = stepIndex === 0;
+ return {
+ id: step.id,
+ // Only the first step should be activated when activating a new guide
+ status: isFirstStep ? 'active' : 'inactive',
+ };
+ });
+
+ const updatedGuide: GuideState = {
+ isActive: true,
+ status: 'in_progress',
+ steps: updatedSteps,
+ guideId,
+ };
+
+ return await this.updateGuideState(updatedGuide, true);
+ }
+ }
+
+ /**
+ * Completes a guide
+ * Updates the overall guide status to 'complete', and marks it as inactive
+ * This is useful for the dropdown panel, when the user clicks the "Continue using Elastic" button after completing all steps
+ * @param {GuideId} guideID the id of the guide (one of search, observability, security)
+ * @return {Promise} a promise with the updated guide state
+ */
+ public async completeGuide(guideId: GuideId): Promise<{ state: GuideState } | undefined> {
+ const guideState = await firstValueFrom(this.fetchActiveGuideState$());
+
+ // For now, returning undefined if consumer attempts to complete a guide that is not active
+ if (guideState?.guideId !== guideId) {
+ return undefined;
+ }
+
+ // All steps should be complete at this point
+ // However, we do a final check here as a safeguard
+ const allStepsComplete =
+ Boolean(guideState.steps.find((step) => step.status !== 'complete')) === false;
+
+ if (allStepsComplete) {
+ const updatedGuide: GuideState = {
+ ...guideState,
+ isActive: false,
+ status: 'complete',
+ };
+
+ return await this.updateGuideState(updatedGuide, false);
+ }
+ }
+
+ /**
+ * An observable with the boolean value if the step is in progress (i.e., user clicked "Start" on a step).
+ * Returns true, if the passed params identify the guide step that is currently in progress.
* Returns false otherwise.
- * @param {string} guideID the id of the guide (one of search, observability, security)
- * @param {string} stepID the id of the step in the guide
+ * @param {GuideId} guideId the id of the guide (one of search, observability, security)
+ * @param {GuideStepIds} stepId the id of the step in the guide
* @return {Observable} an observable with the boolean value
*/
- public isGuideStepActive$(guideID: string, stepID: string): Observable {
- return this.fetchGuideState$().pipe(
- map((state) => {
- return state ? state.activeGuide === guideID && state.activeStep === stepID : false;
+ public isGuideStepActive$(guideId: GuideId, stepId: GuideStepIds): Observable {
+ return this.fetchActiveGuideState$().pipe(
+ map((activeGuideState) => {
+ // Return false right away if the guide itself is not active
+ if (activeGuideState?.guideId !== guideId) {
+ return false;
+ }
+
+ // If the guide is active, next check the step
+ const selectedStep = activeGuideState.steps.find((step) => step.id === stepId);
+ return selectedStep ? selectedStep.status === 'in_progress' : false;
})
);
}
+ /**
+ * Updates the selected step to 'in_progress' state
+ * This is useful for the dropdown panel, when the user clicks the "Start" button for the active step
+ * @param {GuideId} guideId the id of the guide (one of search, observability, security)
+ * @param {GuideStepIds} stepId the id of the step
+ * @return {Promise} a promise with the updated guide state
+ */
+ public async startGuideStep(
+ guideId: GuideId,
+ stepId: GuideStepIds
+ ): Promise<{ state: GuideState } | undefined> {
+ const guideState = await firstValueFrom(this.fetchActiveGuideState$());
+
+ // For now, returning undefined if consumer attempts to start a step for a guide that isn't active
+ if (guideState?.guideId !== guideId) {
+ return undefined;
+ }
+
+ const updatedSteps: GuideStep[] = guideState.steps.map((step) => {
+ // Mark the current step as in_progress
+ if (step.id === stepId) {
+ return {
+ id: step.id,
+ status: 'in_progress',
+ };
+ }
+
+ // All other steps return as-is
+ return step;
+ });
+
+ const currentGuide: GuideState = {
+ guideId,
+ isActive: true,
+ status: 'in_progress',
+ steps: updatedSteps,
+ };
+
+ return await this.updateGuideState(currentGuide, false);
+ }
+
/**
* Completes the guide step identified by the passed params.
* A noop if the passed step is not active.
- * Completes the current guide, if the step is the last one in the guide.
- * @param {string} guideID the id of the guide (one of search, observability, security)
- * @param {string} stepID the id of the step in the guide
+ * @param {GuideId} guideId the id of the guide (one of search, observability, security)
+ * @param {GuideStepIds} stepId the id of the step in the guide
* @return {Promise} a promise with the updated state or undefined if the operation fails
*/
public async completeGuideStep(
- guideID: string,
- stepID: string
- ): Promise<{ state: GuidedOnboardingState } | undefined> {
- const isStepActive = await firstValueFrom(this.isGuideStepActive$(guideID, stepID));
- if (isStepActive) {
- if (isLastStep(guideID, stepID)) {
- await this.updateGuideState({ activeGuide: guideID as UseCase, activeStep: 'completed' });
- } else {
- const nextStepID = getNextStep(guideID, stepID);
- if (nextStepID !== undefined) {
- await this.updateGuideState({
- activeGuide: guideID as UseCase,
- activeStep: nextStepID,
- });
- }
- }
+ guideId: GuideId,
+ stepId: GuideStepIds
+ ): Promise<{ state: GuideState } | undefined> {
+ const guideState = await firstValueFrom(this.fetchActiveGuideState$());
+
+ // For now, returning undefined if consumer attempts to complete a step for a guide that isn't active
+ if (guideState?.guideId !== guideId) {
+ return undefined;
}
+
+ const currentStepIndex = guideState.steps.findIndex((step) => step.id === stepId);
+ const currentStep = guideState.steps[currentStepIndex];
+ const isCurrentStepInProgress = currentStep ? currentStep.status === 'in_progress' : false;
+
+ if (isCurrentStepInProgress) {
+ const updatedSteps: GuideStep[] = guideState.steps.map((step, stepIndex) => {
+ const isCurrentStep = step.id === currentStep!.id;
+ const isNextStep = stepIndex === currentStepIndex + 1;
+
+ // Mark the current step as complete
+ if (isCurrentStep) {
+ return {
+ id: step.id,
+ status: 'complete',
+ };
+ }
+
+ // Update the next step to active status
+ if (isNextStep) {
+ return {
+ id: step.id,
+ status: 'active',
+ };
+ }
+
+ // All other steps return as-is
+ return step;
+ });
+
+ const currentGuide: GuideState = {
+ guideId,
+ isActive: true,
+ status: isLastStep(guideId, stepId) ? 'ready_to_complete' : 'in_progress',
+ steps: updatedSteps,
+ };
+
+ return await this.updateGuideState(currentGuide, true);
+ }
+
return undefined;
}
}
diff --git a/src/plugins/guided_onboarding/public/services/helpers.test.ts b/src/plugins/guided_onboarding/public/services/helpers.test.ts
index 6e1a3cc3e004..bc09a9185424 100644
--- a/src/plugins/guided_onboarding/public/services/helpers.test.ts
+++ b/src/plugins/guided_onboarding/public/services/helpers.test.ts
@@ -7,11 +7,10 @@
*/
import { guidesConfig } from '../constants/guides_config';
-import { getNextStep, isLastStep } from './helpers';
+import { isLastStep } from './helpers';
const searchGuide = 'search';
const firstStep = guidesConfig[searchGuide].steps[0].id;
-const secondStep = guidesConfig[searchGuide].steps[1].id;
const lastStep = guidesConfig[searchGuide].steps[2].id;
describe('GuidedOnboarding ApiService helpers', () => {
@@ -27,21 +26,4 @@ describe('GuidedOnboarding ApiService helpers', () => {
expect(result).toBe(false);
});
});
-
- describe('getNextStep', () => {
- it('returns id of the next step', () => {
- const result = getNextStep(searchGuide, firstStep);
- expect(result).toEqual(secondStep);
- });
-
- it('returns undefined if the params are not part of the config', () => {
- const result = getNextStep('some_guide', 'some_step');
- expect(result).toBeUndefined();
- });
-
- it(`returns undefined if it's the last step`, () => {
- const result = getNextStep(searchGuide, lastStep);
- expect(result).toBeUndefined();
- });
- });
});
diff --git a/src/plugins/guided_onboarding/public/services/helpers.ts b/src/plugins/guided_onboarding/public/services/helpers.ts
index 3eb0bfca9b75..ea4245be9915 100644
--- a/src/plugins/guided_onboarding/public/services/helpers.ts
+++ b/src/plugins/guided_onboarding/public/services/helpers.ts
@@ -6,12 +6,13 @@
* Side Public License, v 1.
*/
+import type { GuideId } from '../../common/types';
import { guidesConfig } from '../constants/guides_config';
-import { GuideConfig, StepConfig, UseCase } from '../types';
+import type { GuideConfig, StepConfig } from '../types';
export const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) {
- return guidesConfig[guideID as UseCase];
+ return guidesConfig[guideID as GuideId];
}
};
@@ -32,11 +33,3 @@ export const isLastStep = (guideID: string, stepID: string): boolean => {
}
return false;
};
-
-export const getNextStep = (guideID: string, stepID: string): string | undefined => {
- const guide = getGuideConfig(guideID);
- const activeStepIndex = getStepIndex(guideID, stepID);
- if (activeStepIndex > -1 && guide?.steps[activeStepIndex + 1]) {
- return guide?.steps[activeStepIndex + 1].id;
- }
-};
diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts
index 7925fa8ae69d..4a16c16336c6 100755
--- a/src/plugins/guided_onboarding/public/types.ts
+++ b/src/plugins/guided_onboarding/public/types.ts
@@ -7,6 +7,7 @@
*/
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
+import { GuideId, GuideStepIds, StepStatus } from '../common/types';
import { ApiService } from './services/api';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -20,11 +21,12 @@ export interface AppPluginStartDependencies {
navigation: NavigationPublicPluginStart;
}
-export type UseCase = 'observability' | 'security' | 'search';
-export type StepStatus = 'incomplete' | 'complete' | 'in_progress';
+export interface ClientConfigType {
+ ui: boolean;
+}
export interface StepConfig {
- id: string;
+ id: GuideStepIds;
title: string;
descriptionList: string[];
location?: {
@@ -33,7 +35,6 @@ export interface StepConfig {
};
status?: StepStatus;
}
-
export interface GuideConfig {
title: string;
description: string;
@@ -45,14 +46,5 @@ export interface GuideConfig {
}
export type GuidesConfig = {
- [key in UseCase]: GuideConfig;
+ [key in GuideId]: GuideConfig;
};
-
-export interface GuidedOnboardingState {
- activeGuide: UseCase | 'unset';
- activeStep: string | 'unset' | 'completed';
-}
-
-export interface ClientConfigType {
- ui: boolean;
-}
diff --git a/src/plugins/guided_onboarding/server/routes/index.ts b/src/plugins/guided_onboarding/server/routes/index.ts
index e4e4fcaae505..cce5aad08b1e 100755
--- a/src/plugins/guided_onboarding/server/routes/index.ts
+++ b/src/plugins/guided_onboarding/server/routes/index.ts
@@ -7,92 +7,154 @@
*/
import { schema } from '@kbn/config-schema';
-import { IRouter, SavedObjectsClient } from '@kbn/core/server';
-import {
- guidedSetupDefaultState,
- guidedSetupSavedObjectsId,
- guidedSetupSavedObjectsType,
-} from '../saved_objects';
+import type { IRouter, SavedObjectsClient } from '@kbn/core/server';
+import type { GuideState } from '../../common/types';
+import { guidedSetupSavedObjectsType } from '../saved_objects';
-const doesGuidedSetupExist = async (savedObjectsClient: SavedObjectsClient): Promise => {
- return savedObjectsClient
- .find({ type: guidedSetupSavedObjectsType })
- .then((foundSavedObjects) => foundSavedObjects.total > 0);
+const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => {
+ return savedObjectsClient.find({
+ type: guidedSetupSavedObjectsType,
+ search: `"${guideId}"`,
+ searchFields: ['guideId'],
+ });
+};
+
+const findActiveGuide = async (savedObjectsClient: SavedObjectsClient) => {
+ return savedObjectsClient.find({
+ type: guidedSetupSavedObjectsType,
+ search: 'true',
+ searchFields: ['isActive'],
+ });
+};
+
+const findAllGuides = async (savedObjectsClient: SavedObjectsClient) => {
+ return savedObjectsClient.find({ type: guidedSetupSavedObjectsType });
};
export function defineRoutes(router: IRouter) {
+ // Fetch all guides state; optionally pass the query param ?active=true to only return the active guide
router.get(
{
path: '/api/guided_onboarding/state',
- validate: false,
+ validate: {
+ query: schema.object({
+ active: schema.maybe(schema.boolean()),
+ }),
+ },
},
async (context, request, response) => {
const coreContext = await context.core;
const soClient = coreContext.savedObjects.client as SavedObjectsClient;
- const stateExists = await doesGuidedSetupExist(soClient);
- if (stateExists) {
- const guidedSetupSO = await soClient.get(
- guidedSetupSavedObjectsType,
- guidedSetupSavedObjectsId
- );
+ const existingGuides =
+ request.query.active === true
+ ? await findActiveGuide(soClient)
+ : await findAllGuides(soClient);
+
+ if (existingGuides.total > 0) {
+ const guidesState = existingGuides.saved_objects.map((guide) => guide.attributes);
return response.ok({
- body: { state: guidedSetupSO.attributes },
+ body: { state: guidesState },
});
} else {
+ // If no SO exists, we assume state hasn't been stored yet and return an empty array
return response.ok({
- body: { state: guidedSetupDefaultState },
+ body: { state: [] },
});
}
}
);
+ // Update the guide state for the passed guideId;
+ // will also check any existing active guides and update them to an "inactive" state
router.put(
{
path: '/api/guided_onboarding/state',
validate: {
body: schema.object({
- activeGuide: schema.maybe(schema.string()),
- activeStep: schema.maybe(schema.string()),
+ status: schema.string(),
+ guideId: schema.string(),
+ isActive: schema.boolean(),
+ steps: schema.arrayOf(
+ schema.object({
+ status: schema.string(),
+ id: schema.string(),
+ })
+ ),
}),
},
},
async (context, request, response) => {
- const activeGuide = request.body.activeGuide;
- const activeStep = request.body.activeStep;
- const attributes = {
- activeGuide: activeGuide ?? 'unset',
- activeStep: activeStep ?? 'unset',
- };
+ const updatedGuideState = request.body;
+
const coreContext = await context.core;
- const soClient = coreContext.savedObjects.client as SavedObjectsClient;
+ const savedObjectsClient = coreContext.savedObjects.client as SavedObjectsClient;
- const stateExists = await doesGuidedSetupExist(soClient);
+ const selectedGuideSO = await findGuideById(savedObjectsClient, updatedGuideState.guideId);
+
+ // If the SO already exists, update it, else create a new SO
+ if (selectedGuideSO.total > 0) {
+ const updatedGuides = [];
+ const selectedGuide = selectedGuideSO.saved_objects[0];
+
+ updatedGuides.push({
+ type: guidedSetupSavedObjectsType,
+ id: selectedGuide.id,
+ attributes: {
+ ...updatedGuideState,
+ },
+ });
+
+ // If we are activating a new guide, we need to check if there is a different, existing active guide
+ // If yes, we need to mark it as inactive (only 1 guide can be active at a time)
+ if (updatedGuideState.isActive) {
+ const activeGuideSO = await findActiveGuide(savedObjectsClient);
+
+ if (activeGuideSO.total > 0) {
+ const activeGuide = activeGuideSO.saved_objects[0];
+ if (activeGuide.attributes.guideId !== updatedGuideState.guideId) {
+ updatedGuides.push({
+ type: guidedSetupSavedObjectsType,
+ id: activeGuide.id,
+ attributes: {
+ ...activeGuide.attributes,
+ isActive: false,
+ },
+ });
+ }
+ }
+ }
+
+ const updatedGuidesResponse = await savedObjectsClient.bulkUpdate(updatedGuides);
- if (stateExists) {
- const updatedGuidedSetupSO = await soClient.update(
- guidedSetupSavedObjectsType,
- guidedSetupSavedObjectsId,
- attributes
- );
return response.ok({
- body: { state: updatedGuidedSetupSO.attributes },
+ body: {
+ state: updatedGuidesResponse,
+ },
});
} else {
- const guidedSetupSO = await soClient.create(
- guidedSetupSavedObjectsType,
- {
- ...guidedSetupDefaultState,
- ...attributes,
- },
- {
- id: guidedSetupSavedObjectsId,
+ // If we are activating a new guide, we need to check if there is an existing active guide
+ // If yes, we need to mark it as inactive (only 1 guide can be active at a time)
+ if (updatedGuideState.isActive) {
+ const activeGuideSO = await findActiveGuide(savedObjectsClient);
+
+ if (activeGuideSO.total > 0) {
+ const activeGuide = activeGuideSO.saved_objects[0];
+ await savedObjectsClient.update(guidedSetupSavedObjectsType, activeGuide.id, {
+ ...activeGuide.attributes,
+ isActive: false,
+ });
}
+ }
+
+ const createdGuideResponse = await savedObjectsClient.create(
+ guidedSetupSavedObjectsType,
+ updatedGuideState
);
return response.ok({
body: {
- state: guidedSetupSO.attributes,
+ state: createdGuideResponse,
},
});
}
diff --git a/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts b/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts
index 257614886859..6fe0a90339f6 100644
--- a/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts
+++ b/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts
@@ -8,12 +8,8 @@
import { SavedObjectsType } from '@kbn/core/server';
-export const guidedSetupSavedObjectsType = 'guided-setup-state';
-export const guidedSetupSavedObjectsId = 'guided-setup-state-id';
-export const guidedSetupDefaultState = {
- activeGuide: 'unset',
- activeStep: 'unset',
-};
+export const guidedSetupSavedObjectsType = 'guided-onboarding-guide-state';
+
export const guidedSetupSavedObjects: SavedObjectsType = {
name: guidedSetupSavedObjectsType,
hidden: false,
@@ -22,11 +18,11 @@ export const guidedSetupSavedObjects: SavedObjectsType = {
mappings: {
dynamic: false,
properties: {
- activeGuide: {
+ guideId: {
type: 'keyword',
},
- activeStep: {
- type: 'keyword',
+ isActive: {
+ type: 'boolean',
},
},
},
diff --git a/src/plugins/guided_onboarding/server/saved_objects/index.ts b/src/plugins/guided_onboarding/server/saved_objects/index.ts
index 2fa5366cc2b9..58195618a0ec 100644
--- a/src/plugins/guided_onboarding/server/saved_objects/index.ts
+++ b/src/plugins/guided_onboarding/server/saved_objects/index.ts
@@ -6,9 +6,4 @@
* Side Public License, v 1.
*/
-export {
- guidedSetupSavedObjects,
- guidedSetupSavedObjectsType,
- guidedSetupSavedObjectsId,
- guidedSetupDefaultState,
-} from './guided_setup';
+export { guidedSetupSavedObjects, guidedSetupSavedObjectsType } from './guided_setup';