mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
f2bb8974f7
commit
081f53a220
11 changed files with 323 additions and 35 deletions
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
19
src/plugins/guided_onboarding/jest.config.js
Normal file
19
src/plugins/guided_onboarding/jest.config.js
Normal 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}',
|
||||
],
|
||||
};
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
133
src/plugins/guided_onboarding/public/services/api.test.ts
Normal file
133
src/plugins/guided_onboarding/public/services/api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
42
src/plugins/guided_onboarding/public/services/helpers.ts
Normal file
42
src/plugins/guided_onboarding/public/services/helpers.ts
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue