[Guided onboarding] Manual step completion (#142884)

* [Guided onboarding] Updated the readme file

* [Guided onboarding] Updated the security guide config with wording and merged alerts and cases together

* [Guided onboarding] Implemented the manual completion for guide steps

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Revert "[Guided onboarding] Updated the readme file"

This reverts commit 5588569947.

* [Guided onboarding] Reverted the security config changes and added manual step completion

* [Guided onboarding] Added jest tests for manual completion

* [Guided onboarding] Fixed tests and types errors

* Update src/plugins/guided_onboarding/public/services/api.test.ts

Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>

* [Guided onboarding] Fixed merge conflict changes and addressed some CR comments

* Update src/plugins/guided_onboarding/public/services/helpers.ts

Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>

* [Guided onboarding] Addressed more comments

* [Guided onboarding] Added completion on navigation

* Update src/plugins/guided_onboarding/public/components/guide_button_popover.tsx

Co-authored-by: Cindy Chang  <cindyisachang@gmail.com>

* Update src/plugins/guided_onboarding/public/components/guide_button_popover.tsx

Co-authored-by: Cindy Chang  <cindyisachang@gmail.com>

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* [Guided onboarding] Added a spacer between title and text in the manual step popover

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>
Co-authored-by: Cindy Chang  <cindyisachang@gmail.com>
This commit is contained in:
Yulia Čech 2022-10-13 11:15:51 +02:00 committed by GitHub
parent 5e57ffcdac
commit 8fbc5cbb71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 463 additions and 200 deletions

View file

@ -27,9 +27,10 @@ export type GuideStatus = 'in_progress' | 'ready_to_complete' | 'complete';
* 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
* ready_to_complete: Step can be manually completed
* complete: Step has been completed
*/
export type StepStatus = 'inactive' | 'active' | 'in_progress' | 'complete';
export type StepStatus = 'inactive' | 'active' | 'in_progress' | 'ready_to_complete' | 'complete';
export interface GuideStep {
id: GuideStepIds;

View file

@ -0,0 +1,82 @@
/*
* 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 from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { GuideState } from '../../common/types';
import { getStepConfig } from '../services/helpers';
import { GuideButtonPopover } from './guide_button_popover';
interface GuideButtonProps {
guideState: GuideState;
toggleGuidePanel: () => void;
isGuidePanelOpen: boolean;
}
const getStepNumber = (state: GuideState): number | undefined => {
let stepNumber: number | undefined;
state.steps.forEach((step, stepIndex) => {
// If the step is in_progress or ready_to_complete, show that step number
if (step.status === 'in_progress' || step.status === 'ready_to_complete') {
stepNumber = stepIndex + 1;
}
// If the step is active, show the previous step number
if (step.status === 'active') {
stepNumber = stepIndex;
}
});
return stepNumber;
};
export const GuideButton = ({
guideState,
toggleGuidePanel,
isGuidePanelOpen,
}: GuideButtonProps) => {
const stepNumber = getStepNumber(guideState);
const stepReadyToComplete = guideState.steps.find((step) => step.status === 'ready_to_complete');
const button = (
<EuiButton
onClick={toggleGuidePanel}
color="success"
fill
size="s"
data-test-subj="guideButton"
>
{Boolean(stepNumber)
? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', {
defaultMessage: 'Setup guide: step {stepNumber}',
values: {
stepNumber,
},
})
: i18n.translate('guidedOnboarding.guidedSetupButtonLabel', {
defaultMessage: 'Setup guide',
})}
</EuiButton>
);
if (stepReadyToComplete) {
const stepConfig = getStepConfig(guideState.guideId, stepReadyToComplete.id);
// check if the stepConfig has manualCompletion info
if (stepConfig && stepConfig.manualCompletion) {
return (
<GuideButtonPopover
button={button}
isGuidePanelOpen={isGuidePanelOpen}
title={stepConfig.manualCompletion.title}
description={stepConfig.manualCompletion.description}
/>
);
}
}
return button;
};

View file

@ -0,0 +1,55 @@
/*
* 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 { useEffect, useRef, useState } from 'react';
import { EuiPopover, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
interface GuideButtonPopoverProps {
button: EuiPopover['button'];
isGuidePanelOpen: boolean;
title?: string;
description?: string;
}
export const GuideButtonPopover = ({
button,
isGuidePanelOpen,
title,
description,
}: GuideButtonPopoverProps) => {
const isFirstRender = useRef(true);
useEffect(() => {
isFirstRender.current = false;
}, []);
const [isPopoverShown, setIsPopoverShown] = useState(true);
useEffect(() => {
// close the popover after it was rendered once and the panel is opened
if (isGuidePanelOpen && !isFirstRender.current) {
setIsPopoverShown(false);
}
}, [isGuidePanelOpen]);
return (
<EuiPopover
data-test-subj="manualCompletionPopover"
button={button}
isOpen={isPopoverShown}
closePopover={() => {
/* do nothing, the popover is closed once the panel is opened */
}}
>
{title && (
<EuiTitle size="xxs">
<h3>{title}</h3>
</EuiTitle>
)}
<EuiSpacer />
<EuiText style={{ width: 300 }}>{description && <p>{description}</p>}</EuiText>
</EuiPopover>
);
};

View file

@ -41,6 +41,45 @@ const mockActiveSearchGuideState: GuideState = {
],
};
const mockInProgressSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'in_progress',
},
mockActiveSearchGuideState.steps[1],
mockActiveSearchGuideState.steps[2],
],
};
const mockReadyToCompleteSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
id: mockActiveSearchGuideState.steps[0].id,
status: 'complete',
},
{
id: mockActiveSearchGuideState.steps[1].id,
status: 'ready_to_complete',
},
mockActiveSearchGuideState.steps[2],
],
};
const updateComponentWithState = async (
component: TestBed['component'],
guideState: GuideState,
isPanelOpen: boolean
) => {
await act(async () => {
await apiService.updateGuideState(guideState, isPanelOpen);
});
component.update();
};
const getGuidePanel = () => () => {
return <GuidePanel application={applicationMock} api={apiService} />;
};
@ -80,12 +119,8 @@ describe('Guided setup', () => {
test('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(mockActiveSearchGuideState, true);
});
component.update();
// Enable the "search" guide
await updateComponentWithState(component, mockActiveSearchGuideState, true);
expect(exists('disabledGuideButton')).toBe(false);
expect(exists('guideButton')).toBe(true);
@ -95,38 +130,41 @@ describe('Guided setup', () => {
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();
await updateComponentWithState(component, mockInProgressSearchGuideState, true);
expect(find('guideButton').text()).toEqual('Setup guide: step 1');
});
test('shows the step number in the button label if a step is ready to complete', async () => {
const { component, find } = testBed;
await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, true);
expect(find('guideButton').text()).toEqual('Setup guide: step 2');
});
test('shows the manual completion popover if a step is ready to complete', async () => {
const { component, exists } = testBed;
await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, false);
expect(exists('manualCompletionPopover')).toBe(true);
});
test('shows no manual completion popover if a step is in progress', async () => {
const { component, exists } = testBed;
await updateComponentWithState(component, mockInProgressSearchGuideState, false);
expect(exists('manualCompletionPopoverPanel')).toBe(false);
});
});
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();
await updateComponentWithState(component, mockActiveSearchGuideState, true);
expect(exists('guidePanel')).toBe(true);
expect(exists('guideProgress')).toBe(false);
@ -136,7 +174,7 @@ describe('Guided setup', () => {
test('should show the progress bar if the first step has been completed', async () => {
const { component, exists } = testBed;
const mockInProgressSearchGuideState: GuideState = {
const mockCompleteSearchGuideState: GuideState = {
...mockActiveSearchGuideState,
steps: [
{
@ -148,11 +186,7 @@ describe('Guided setup', () => {
],
};
await act(async () => {
await apiService.updateGuideState(mockInProgressSearchGuideState, true);
});
component.update();
await updateComponentWithState(component, mockCompleteSearchGuideState, true);
expect(exists('guidePanel')).toBe(true);
expect(exists('guideProgress')).toBe(true);
@ -181,11 +215,7 @@ describe('Guided setup', () => {
],
};
await act(async () => {
await apiService.updateGuideState(readyToCompleteGuideState, true);
});
component.update();
await updateComponentWithState(component, readyToCompleteGuideState, true);
expect(exists('useElasticButton')).toBe(true);
});
@ -194,12 +224,7 @@ describe('Guided setup', () => {
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();
await updateComponentWithState(component, mockActiveSearchGuideState, true);
expect(find('activeStepButtonLabel').text()).toEqual('Start');
});
@ -207,26 +232,18 @@ describe('Guided setup', () => {
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();
await updateComponentWithState(component, mockInProgressSearchGuideState, true);
expect(find('activeStepButtonLabel').text()).toEqual('Continue');
});
test('shows "Mark done" button label if step is ready to complete', async () => {
const { component, find } = testBed;
await updateComponentWithState(component, mockReadyToCompleteSearchGuideState, true);
expect(find('activeStepButtonLabel').text()).toEqual('Mark done');
});
});
describe('Quit guide modal', () => {

View file

@ -26,51 +26,27 @@ import {
useEuiTheme,
} from '@elastic/eui';
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 { GuideState, GuideStepIds } from '../../common/types';
import type { GuideConfig, StepConfig } from '../types';
import { ApplicationStart } from '@kbn/core/public';
import type { GuideState, GuideStep as GuideStepStatus } from '../../common/types';
import type { StepConfig } from '../types';
import type { ApiService } from '../services/api';
import { getGuideConfig } from '../services/helpers';
import { GuideStep } from './guide_panel_step';
import { QuitGuideModal } from './quit_guide_modal';
import { getGuidePanelStyles } from './guide_panel.styles';
import { GuideButton } from './guide_button';
interface GuidePanelProps {
api: ApiService;
application: ApplicationStart;
}
const getConfig = (state?: GuideState): GuideConfig | undefined => {
if (state) {
return guidesConfig[state.guideId];
}
return undefined;
};
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;
}
// If the step is active, show the previous step number
if (step.status === 'active') {
stepNumber = stepIndex;
}
});
return stepNumber;
};
const getProgress = (state?: GuideState): number => {
if (state) {
return state.steps.reduce((acc, currentVal) => {
@ -95,10 +71,26 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen);
};
const navigateToStep = async (stepId: GuideStepIds, stepLocation: StepConfig['location']) => {
await api.startGuideStep(guideState!.guideId, stepId);
if (stepLocation) {
application.navigateToApp(stepLocation.appID, { path: stepLocation.path });
const handleStepButtonClick = async (step: GuideStepStatus, stepConfig: StepConfig) => {
if (guideState) {
const { id, status } = step;
if (status === 'ready_to_complete') {
return await api.completeGuideStep(guideState?.guideId, id);
}
if (status === 'active') {
await api.startGuideStep(guideState!.guideId, id);
}
if (status === 'active' || status === 'in_progress') {
if (stepConfig.location) {
await application.navigateToApp(stepConfig.location.appID, {
path: stepConfig.location.path,
});
if (stepConfig.manualCompletion?.readyToCompleteOnNavigation) {
await api.completeGuideStep(guideState.guideId, id);
}
}
}
}
};
@ -136,7 +128,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
return () => subscription.unsubscribe();
}, [api]);
const guideConfig = getConfig(guideState);
const guideConfig = getGuideConfig(guideState?.guideId);
// TODO handle loading, error state
// https://github.com/elastic/kibana/issues/139799, https://github.com/elastic/kibana/issues/139798
@ -157,23 +149,15 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
);
}
const stepNumber = getStepNumber(guideState);
const stepsCompleted = getProgress(guideState);
return (
<>
<EuiButton onClick={toggleGuide} color="success" fill size="s" data-test-subj="guideButton">
{Boolean(stepNumber)
? i18n.translate('guidedOnboarding.guidedSetupStepButtonLabel', {
defaultMessage: 'Setup guide: step {stepNumber}',
values: {
stepNumber,
},
})
: i18n.translate('guidedOnboarding.guidedSetupButtonLabel', {
defaultMessage: 'Setup guide',
})}
</EuiButton>
<GuideButton
guideState={guideState!}
toggleGuidePanel={toggleGuide}
isGuidePanelOpen={isGuideOpen}
/>
{isGuideOpen && (
<EuiFlyout
@ -259,7 +243,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => {
stepStatus={stepState.status}
stepConfig={step}
stepNumber={index + 1}
navigateToStep={navigateToStep}
handleButtonClick={() => handleStepButtonClick(stepState, step)}
key={accordionId}
/>
);

View file

@ -20,7 +20,8 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { StepStatus, GuideStepIds } from '../../common/types';
import type { StepStatus } from '../../common/types';
import type { StepConfig } from '../types';
import { getGuidePanelStepStyles } from './guide_panel_step.styles';
@ -29,7 +30,7 @@ interface GuideStepProps {
stepStatus: StepStatus;
stepConfig: StepConfig;
stepNumber: number;
navigateToStep: (stepId: GuideStepIds, stepLocation: StepConfig['location']) => void;
handleButtonClick: () => void;
}
export const GuideStep = ({
@ -37,7 +38,7 @@ export const GuideStep = ({
stepStatus,
stepNumber,
stepConfig,
navigateToStep,
handleButtonClick,
}: GuideStepProps) => {
const { euiTheme } = useEuiTheme();
const styles = getGuidePanelStepStyles(euiTheme, stepStatus);
@ -58,6 +59,26 @@ export const GuideStep = ({
</EuiFlexItem>
</EuiFlexGroup>
);
const isAccordionOpen =
stepStatus === 'in_progress' || stepStatus === 'active' || stepStatus === 'ready_to_complete';
const getStepButtonLabel = (): string => {
switch (stepStatus) {
case 'active':
return i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
defaultMessage: 'Start',
});
case 'in_progress':
return i18n.translate('guidedOnboarding.dropdownPanel.continueStepButtonLabel', {
defaultMessage: 'Continue',
});
case 'ready_to_complete':
return i18n.translate('guidedOnboarding.dropdownPanel.markDoneStepButtonLabel', {
defaultMessage: 'Mark done',
});
}
return '';
};
return (
<div data-test-subj="guidePanelStep">
@ -68,7 +89,7 @@ export const GuideStep = ({
id={accordionId}
buttonContent={stepTitleContent}
arrowDisplay="right"
initialIsOpen={stepStatus === 'in_progress' || stepStatus === 'active'}
initialIsOpen={isAccordionOpen}
>
<>
<EuiSpacer size="s" />
@ -82,21 +103,15 @@ export const GuideStep = ({
</EuiText>
<EuiSpacer />
{(stepStatus === 'in_progress' || stepStatus === 'active') && (
{isAccordionOpen && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => navigateToStep(stepConfig.id, stepConfig.location)}
onClick={() => handleButtonClick()}
fill
data-test-subj="activeStepButtonLabel"
>
{stepStatus === 'active'
? i18n.translate('guidedOnboarding.dropdownPanel.startStepButtonLabel', {
defaultMessage: 'Start',
})
: i18n.translate('guidedOnboarding.dropdownPanel.continueStepButtonLabel', {
defaultMessage: 'Continue',
})}
{getStepButtonLabel()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -41,6 +41,12 @@ export const searchConfig: GuideConfig = {
appID: 'guidedOnboardingExample',
path: 'stepTwo',
},
manualCompletion: {
title: 'Manual completion step title',
description:
'Mark the step complete by opening the panel and clicking the button "Mark done"',
readyToCompleteOnNavigation: true,
},
},
{
id: 'search_experience',

View file

@ -35,6 +35,11 @@ export const securityConfig: 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.',
],
manualCompletion: {
title: 'Manual completion step title',
description:
'Mark the step complete by opening the panel and clicking the button "Mark done"',
},
},
{
id: 'alertsCases',

View file

@ -28,26 +28,6 @@ export const searchAddDataActiveState: GuideState = {
],
};
export const searchAddDataInProgressState: GuideState = {
isActive: true,
status: 'in_progress',
steps: [
{
id: 'add_data',
status: 'in_progress',
},
{
id: 'browse_docs',
status: 'inactive',
},
{
id: 'search_experience',
status: 'inactive',
},
],
guideId: 'search',
};
export const securityAddDataInProgressState: GuideState = {
guideId: 'security',
status: 'in_progress',
@ -61,10 +41,14 @@ export const securityAddDataInProgressState: GuideState = {
id: 'rules',
status: 'inactive',
},
{
id: 'alertsCases',
status: 'inactive',
},
],
};
export const securityRulesActivesState: GuideState = {
export const securityRulesActiveState: GuideState = {
guideId: 'security',
isActive: true,
status: 'in_progress',
@ -77,6 +61,10 @@ export const securityRulesActivesState: GuideState = {
id: 'rules',
status: 'active',
},
{
id: 'alertsCases',
status: 'inactive',
},
],
};
@ -93,5 +81,9 @@ export const noGuideActiveState: GuideState = {
id: 'rules',
status: 'inactive',
},
{
id: 'alertsCases',
status: 'inactive',
},
],
};

View file

@ -18,7 +18,7 @@ import {
noGuideActiveState,
searchAddDataActiveState,
securityAddDataInProgressState,
securityRulesActivesState,
securityRulesActiveState,
} from './api.mocks';
const searchGuide = 'search';
@ -315,17 +315,58 @@ describe('GuidedOnboarding ApiService', () => {
});
});
it(`marks the step as 'ready_to_complete' if it's configured for manual completion`, async () => {
const securityRulesInProgressState = {
...securityRulesActiveState,
steps: [
securityRulesActiveState.steps[0],
{
id: securityRulesActiveState.steps[1].id,
status: 'in_progress',
},
securityRulesActiveState.steps[2],
],
};
httpClient.get.mockResolvedValue({
state: [securityRulesInProgressState],
});
apiService.setup(httpClient);
await apiService.completeGuideStep('security', 'rules');
expect(httpClient.put).toHaveBeenCalledTimes(1);
// Verify the completed step now has a "ready_to_complete" status, and the subsequent step is "inactive"
expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify({
...securityRulesInProgressState,
steps: [
securityRulesInProgressState.steps[0],
{
id: securityRulesInProgressState.steps[1].id,
status: 'ready_to_complete',
},
{
id: securityRulesInProgressState.steps[2].id,
status: 'inactive',
},
],
}),
});
});
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 in progress', async () => {
await apiService.updateGuideState(searchAddDataActiveState, false);
httpClient.get.mockResolvedValue({
state: [searchAddDataActiveState],
});
apiService.setup(httpClient);
await apiService.completeGuideStep(searchGuide, firstStep);
// Expect only 1 call from updateGuideState()
expect(httpClient.put).toHaveBeenCalledTimes(1);
expect(httpClient.put).toHaveBeenCalledTimes(0);
});
});
@ -384,7 +425,7 @@ describe('GuidedOnboarding ApiService', () => {
expect(httpClient.put).toHaveBeenCalledTimes(1);
// this assertion depends on the guides config
expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, {
body: JSON.stringify(securityRulesActivesState),
body: JSON.stringify(securityRulesActiveState),
});
});

View file

@ -13,8 +13,12 @@ import { GuidedOnboardingApi } from '../types';
import {
getGuideConfig,
getInProgressStepId,
getStepConfig,
getUpdatedSteps,
isIntegrationInGuideStep,
isLastStep,
isStepInProgress,
isStepReadyToComplete,
} from './helpers';
import { API_BASE_PATH } from '../../common/constants';
import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types';
@ -210,16 +214,7 @@ export class ApiService implements GuidedOnboardingApi {
*/
public isGuideStepActive$(guideId: GuideId, stepId: GuideStepIds): Observable<boolean> {
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;
})
map((activeGuideState) => isStepInProgress(activeGuideState, guideId, stepId))
);
}
@ -282,34 +277,20 @@ export class ApiService implements GuidedOnboardingApi {
return undefined;
}
const currentStepIndex = guideState.steps.findIndex((step) => step.id === stepId);
const currentStep = guideState.steps[currentStepIndex];
const isCurrentStepInProgress = currentStep ? currentStep.status === 'in_progress' : false;
const isCurrentStepInProgress = isStepInProgress(guideState, guideId, stepId);
const isCurrentStepReadyToComplete = isStepReadyToComplete(guideState, guideId, stepId);
if (isCurrentStepInProgress) {
const updatedSteps: GuideStep[] = guideState.steps.map((step, stepIndex) => {
const isCurrentStep = step.id === currentStep!.id;
const isNextStep = stepIndex === currentStepIndex + 1;
const stepConfig = getStepConfig(guideState.guideId, stepId);
const isManualCompletion = stepConfig ? !!stepConfig.manualCompletion : false;
// 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;
});
if (isCurrentStepInProgress || isCurrentStepReadyToComplete) {
const updatedSteps = getUpdatedSteps(
guideState,
stepId,
// if current step is in progress and configured for manual completion,
// set the status to ready_to_complete
isManualCompletion && isCurrentStepInProgress
);
const currentGuide: GuideState = {
guideId,
@ -318,7 +299,13 @@ export class ApiService implements GuidedOnboardingApi {
steps: updatedSteps,
};
return await this.updateGuideState(currentGuide, true);
return await this.updateGuideState(
currentGuide,
// the panel is opened when the step is being set to complete.
// that happens when the step is not configured for manual completion
// or it's already ready_to_complete
!isManualCompletion || isCurrentStepReadyToComplete
);
}
return undefined;

View file

@ -11,7 +11,7 @@ import { isIntegrationInGuideStep, isLastStep } from './helpers';
import {
noGuideActiveState,
securityAddDataInProgressState,
securityRulesActivesState,
securityRulesActiveState,
} from './api.mocks';
const searchGuide = 'search';
@ -42,7 +42,7 @@ describe('GuidedOnboarding ApiService helpers', () => {
expect(result).toBe(false);
});
it('returns false if no integration is defined in the guide step', () => {
const result = isIntegrationInGuideStep(securityRulesActivesState, 'endpoint');
const result = isIntegrationInGuideStep(securityRulesActiveState, 'endpoint');
expect(result).toBe(false);
});
it('returns false if no guide is active', () => {

View file

@ -9,24 +9,30 @@
import type { GuideId, GuideState, GuideStepIds } from '../../common/types';
import { guidesConfig } from '../constants/guides_config';
import { GuideConfig, StepConfig } from '../types';
import { GuideStep } from '../../common/types';
export const getGuideConfig = (guideID?: string): GuideConfig | undefined => {
if (guideID && Object.keys(guidesConfig).includes(guideID)) {
return guidesConfig[guideID as GuideId];
export const getGuideConfig = (guideId?: GuideId): GuideConfig | undefined => {
if (guideId && Object.keys(guidesConfig).includes(guideId)) {
return guidesConfig[guideId];
}
};
const getStepIndex = (guideID: string, stepID: string): number => {
const guide = getGuideConfig(guideID);
export const getStepConfig = (guideId: GuideId, stepId: GuideStepIds): StepConfig | undefined => {
const guideConfig = getGuideConfig(guideId);
return guideConfig?.steps.find((step) => step.id === stepId);
};
const getStepIndex = (guideId: GuideId, stepId: GuideStepIds): number => {
const guide = getGuideConfig(guideId);
if (guide) {
return guide.steps.findIndex((step: StepConfig) => step.id === stepID);
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);
export const isLastStep = (guideId: GuideId, stepId: GuideStepIds): boolean => {
const guide = getGuideConfig(guideId);
const activeStepIndex = getStepIndex(guideId, stepId);
const stepsNumber = guide?.steps.length || 0;
if (stepsNumber > 0) {
return activeStepIndex === stepsNumber - 1;
@ -56,3 +62,70 @@ export const isIntegrationInGuideStep = (state: GuideState, integration?: string
}
return false;
};
const isGuideActive = (guideState: GuideState | undefined, guideId: GuideId): boolean => {
// false if guideState is undefined or the guide is not active
return !!(guideState && guideState.isActive && guideState.guideId === guideId);
};
export const isStepInProgress = (
guideState: GuideState | undefined,
guideId: GuideId,
stepId: GuideStepIds
): boolean => {
if (!isGuideActive(guideState, guideId)) {
return false;
}
// false if the step is not 'in_progress'
const selectedStep = guideState!.steps.find((step) => step.id === stepId);
return selectedStep ? selectedStep.status === 'in_progress' : false;
};
export const isStepReadyToComplete = (
guideState: GuideState | undefined,
guideId: GuideId,
stepId: GuideStepIds
): boolean => {
if (!isGuideActive(guideState, guideId)) {
return false;
}
// false if the step is not 'ready_to_complete'
const selectedStep = guideState!.steps.find((step) => step.id === stepId);
return selectedStep ? selectedStep.status === 'ready_to_complete' : false;
};
export const getUpdatedSteps = (
guideState: GuideState,
stepId: GuideStepIds,
setToReadyToComplete?: boolean
): GuideStep[] => {
const currentStepIndex = guideState.steps.findIndex((step) => step.id === stepId);
const currentStep = guideState.steps[currentStepIndex];
return guideState.steps.map((step, stepIndex) => {
const isCurrentStep = step.id === currentStep!.id;
const isNextStep = stepIndex === currentStepIndex + 1;
if (isCurrentStep) {
return {
id: step.id,
status: setToReadyToComplete ? 'ready_to_complete' : 'complete',
};
}
// if the current step is being updated to 'ready_to_complete, the next step stays inactive
// otherwise update the next step to active status
if (isNextStep) {
return setToReadyToComplete
? step
: {
id: step.id,
status: 'active',
};
}
// All other steps return as-is
return step;
});
};

View file

@ -65,6 +65,11 @@ export interface StepConfig {
};
status?: StepStatus;
integration?: string;
manualCompletion?: {
title: string;
description: string;
readyToCompleteOnNavigation?: boolean;
};
}
export interface GuideConfig {
title: string;