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';