[Guided onboarding] Added api functions for the active step (#141045)

* [Guided onboarding] Initial service function to check if the step is active and to complete a step

* [Guided onboarding] Added unit tests for api and helpers

* [Guided onboarding] Added jsdoc comments to the api methods
This commit is contained in:
Yulia Čech 2022-09-21 14:02:29 +02:00 committed by GitHub
parent f2bb8974f7
commit 081f53a220
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 323 additions and 35 deletions

View file

@ -10,9 +10,10 @@ import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from '@kbn/core/public';
import {
EuiButton,
EuiFieldNumber,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
@ -29,7 +30,6 @@ import {
GuidedOnboardingState,
UseCase,
} from '@kbn/guided-onboarding-plugin/public';
import { CoreStart } from '@kbn/core/public';
interface MainProps {
guidedOnboarding: GuidedOnboardingPluginStart;
@ -51,9 +51,11 @@ export const Main = (props: MainProps) => {
);
useEffect(() => {
const subscription = guidedOnboardingApi?.fetchGuideState$().subscribe((newState) => {
setGuideState(newState);
});
const subscription = guidedOnboardingApi
?.fetchGuideState$()
.subscribe((newState: GuidedOnboardingState) => {
setGuideState(newState);
});
return () => subscription?.unsubscribe();
}, [guidedOnboardingApi]);
@ -208,7 +210,7 @@ export const Main = (props: MainProps) => {
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="Step">
<EuiFieldNumber
<EuiFieldText
value={selectedStep}
onChange={(e) => setSelectedStep(e.target.value)}
/>

View file

@ -18,6 +18,8 @@ import {
EuiSpacer,
} from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public/types';
interface GuidedOnboardingExampleAppDeps {
@ -28,17 +30,14 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) =>
const { guidedOnboardingApi } = guidedOnboarding;
const [isTourStepOpen, setIsTourStepOpen] = useState<boolean>(false);
const isTourActive = useObservable(
guidedOnboardingApi!.isGuideStepActive$('search', 'add_data'),
false
);
useEffect(() => {
const subscription = guidedOnboardingApi?.fetchGuideState$().subscribe((newState) => {
const { activeGuide: guide, activeStep: step } = newState;
if (guide === 'search' && step === 'add_data') {
setIsTourStepOpen(true);
}
});
return () => subscription?.unsubscribe();
}, [guidedOnboardingApi]);
setIsTourStepOpen(isTourActive);
}, [isTourActive]);
return (
<>
<EuiPageContentHeader>
@ -79,10 +78,7 @@ export const StepOne = ({ guidedOnboarding }: GuidedOnboardingExampleAppDeps) =>
>
<EuiButton
onClick={async () => {
await guidedOnboardingApi?.updateGuideState({
activeGuide: 'search',
activeStep: 'search_experience',
});
await guidedOnboardingApi?.completeGuideStep('search', 'add_data');
}}
>
Complete step 1

View file

@ -80,10 +80,7 @@ export const StepTwo = (props: StepTwoProps) => {
>
<EuiButton
onClick={async () => {
await guidedOnboardingApi?.updateGuideState({
activeGuide: 'search',
activeStep: 'optimize',
});
await guidedOnboardingApi?.completeGuideStep('search', 'search_experience');
}}
>
Complete step 2

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/guided_onboarding'],
testRunner: 'jasmine2',
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/guided_onboarding',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/src/plugins/guided_onboarding/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -31,7 +31,7 @@ import {
import { ApplicationStart } from '@kbn/core-application-browser';
import { HttpStart } from '@kbn/core-http-browser';
import { i18n } from '@kbn/i18n';
import { guidesConfig } from '../constants';
import { guidesConfig } from '../constants/guides_config';
import type { GuideConfig, StepStatus, GuidedOnboardingState, StepConfig } from '../types';
import type { ApiService } from '../services/api';

View file

@ -6,14 +6,10 @@
* Side Public License, v 1.
*/
import { GuidesConfig } from '../types';
import { securityConfig } from './security';
import { observabilityConfig } from './observability';
import { searchConfig } from './search';
import type { GuideConfig, UseCase } from '../types';
type GuidesConfig = {
[key in UseCase]: GuideConfig;
};
export const guidesConfig: GuidesConfig = {
security: securityConfig,

View file

@ -0,0 +1,133 @@
/*
* 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 { 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 '..';
const searchGuide = 'search';
const firstStep = 'add_data';
const secondStep = 'search_experience';
const lastStep = 'review';
describe('GuidedOnboarding ApiService', () => {
let httpClient: jest.Mocked<HttpSetup>;
let apiService: ApiService;
let subscription: Subscription;
beforeEach(() => {
httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' });
httpClient.get.mockResolvedValue({
state: { activeGuide: searchGuide, activeStep: firstStep },
});
apiService = new ApiService();
apiService.setup(httpClient);
});
afterEach(() => {
if (subscription) {
subscription.unsubscribe();
}
jest.restoreAllMocks();
});
describe('fetchGuideState$', () => {
it('sends a request to the get API', () => {
subscription = apiService.fetchGuideState$().subscribe();
expect(httpClient.get).toHaveBeenCalledTimes(1);
expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`);
});
it('broadcasts the updated state', async () => {
await apiService.updateGuideState({
activeGuide: searchGuide,
activeStep: secondStep,
});
const state = await firstValueFrom(apiService.fetchGuideState$());
expect(state).toEqual({ activeGuide: searchGuide, activeStep: secondStep });
});
});
describe('updateGuideState', () => {
it('sends a request to the put API', async () => {
const state = {
activeGuide: searchGuide,
activeStep: secondStep,
};
await apiService.updateGuideState(state as GuidedOnboardingState);
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify(state),
});
});
});
describe('isGuideStepActive$', () => {
it('returns true if the step is active', async (done) => {
subscription = apiService
.isGuideStepActive$(searchGuide, firstStep)
.subscribe((isStepActive) => {
if (isStepActive) {
done();
}
});
});
it('returns false if the step is not active', async (done) => {
subscription = apiService
.isGuideStepActive$(searchGuide, secondStep)
.subscribe((isStepActive) => {
if (!isStepActive) {
done();
}
});
});
});
describe('completeGuideStep', () => {
it(`completes the step when it's active`, async () => {
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`, {
body: JSON.stringify({
activeGuide: searchGuide,
activeStep: secondStep,
}),
});
});
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(`does nothing if the step is not active`, async () => {
await apiService.completeGuideStep(searchGuide, secondStep);
expect(httpClient.put).not.toHaveBeenCalled();
});
});
});

View file

@ -7,10 +7,11 @@
*/
import { HttpSetup } from '@kbn/core/public';
import { BehaviorSubject, map, from, concatMap, of } from 'rxjs';
import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs';
import { API_BASE_PATH } from '../../common';
import { GuidedOnboardingState } from '../types';
import { GuidedOnboardingState, UseCase } from '../types';
import { getNextStep, isLastStep } from './helpers';
export class ApiService {
private client: HttpSetup | undefined;
@ -21,7 +22,12 @@ export class ApiService {
this.onboardingGuideState$ = new BehaviorSubject<GuidedOnboardingState | undefined>(undefined);
}
public fetchGuideState$() {
/**
* An Observable with the guided onboarding state.
* Initially the state is fetched from the backend.
* Subsequently, the observable is updated automatically, when the state changes.
*/
public fetchGuideState$(): Observable<GuidedOnboardingState> {
// TODO add error handling if this.client has not been initialized or request fails
return this.onboardingGuideState$.pipe(
concatMap((state) =>
@ -34,7 +40,14 @@ export class ApiService {
);
}
public async updateGuideState(newState: GuidedOnboardingState) {
/**
* 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
*/
public async updateGuideState(
newState: GuidedOnboardingState
): Promise<{ state: GuidedOnboardingState } | undefined> {
if (!this.client) {
throw new Error('ApiService has not be initialized.');
}
@ -54,6 +67,51 @@ export class ApiService {
console.error(error);
}
}
/**
* 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.
* 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
* @return {Observable} an observable with the boolean value
*/
public isGuideStepActive$(guideID: string, stepID: string): Observable<boolean> {
return this.fetchGuideState$().pipe(
map((state) => {
return state ? state.activeGuide === guideID && state.activeStep === stepID : false;
})
);
}
/**
* 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
* @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,
});
}
}
}
return undefined;
}
}
export const apiService = new ApiService();

View file

@ -0,0 +1,41 @@
/*
* 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 { getNextStep, isLastStep } from './helpers';
describe('GuidedOnboarding ApiService helpers', () => {
// this test suite depends on the guides config
describe('isLastStepActive', () => {
it('returns true if the passed params are for the last step', () => {
const result = isLastStep('search', 'review');
expect(result).toBe(true);
});
it('returns false if the passed params are not for the last step', () => {
const result = isLastStep('search', 'add_data');
expect(result).toBe(false);
});
});
describe('getNextStep', () => {
it('returns id of the next step', () => {
const result = getNextStep('search', 'add_data');
expect(result).toEqual('search_experience');
});
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('search', 'review');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { guidesConfig } from '../constants/guides_config';
import { GuideConfig, StepConfig, UseCase } from '../types';
const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) {
return guidesConfig[guideID as UseCase];
}
};
const getStepIndex = (guideID: string, stepID: string): number => {
const guide = getGuideConfig(guideID);
if (guide) {
return guide.steps.findIndex((step: StepConfig) => step.id === stepID);
}
return -1;
};
export const isLastStep = (guideID: string, stepID: string): boolean => {
const guide = getGuideConfig(guideID);
const activeStepIndex = getStepIndex(guideID, stepID);
const stepsNumber = guide?.steps.length || 0;
if (stepsNumber > 0) {
return activeStepIndex === stepsNumber - 1;
}
return false;
};
export const getNextStep = (guideID: string, stepID: string): string | undefined => {
const guide = getGuideConfig(guideID);
const activeStepIndex = getStepIndex(guideID, stepID);
if (activeStepIndex > -1 && guide?.steps[activeStepIndex + 1]) {
return guide?.steps[activeStepIndex + 1].id;
}
};

View file

@ -44,9 +44,13 @@ export interface GuideConfig {
steps: StepConfig[];
}
export type GuidesConfig = {
[key in UseCase]: GuideConfig;
};
export interface GuidedOnboardingState {
activeGuide: UseCase | 'unset';
activeStep: string | 'unset';
activeStep: string | 'unset' | 'completed';
}
export interface ClientConfigType {