mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
5e57ffcdac
commit
8fbc5cbb71
14 changed files with 463 additions and 200 deletions
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -65,6 +65,11 @@ export interface StepConfig {
|
|||
};
|
||||
status?: StepStatus;
|
||||
integration?: string;
|
||||
manualCompletion?: {
|
||||
title: string;
|
||||
description: string;
|
||||
readyToCompleteOnNavigation?: boolean;
|
||||
};
|
||||
}
|
||||
export interface GuideConfig {
|
||||
title: string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue