diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap index ff31b3da30808..a5fe07d1a872c 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap @@ -35,6 +35,7 @@ exports[`guide card footer snapshots should render the footer when the guide has color="primary" data-test-subj="onboarding--guideCard--view--search" fill={true} + isLoading={false} onClick={[Function]} size="m" > @@ -56,6 +57,7 @@ exports[`guide card footer snapshots should render the footer when the guide has color="primary" data-test-subj="onboarding--guideCard--view--search" fill={true} + isLoading={false} onClick={[Function]} size="m" > @@ -100,6 +102,7 @@ exports[`guide card footer snapshots should render the footer when the guide is color="primary" data-test-subj="onboarding--guideCard--continue--search" fill={true} + isLoading={false} onClick={[Function]} size="m" > @@ -145,6 +148,7 @@ exports[`guide card footer snapshots should render the footer when the guide is color="primary" data-test-subj="onboarding--guideCard--continue--search" fill={true} + isLoading={false} onClick={[Function]} size="m" > @@ -166,6 +170,7 @@ exports[`guide card footer snapshots should render the footer when the guided on color="primary" data-test-subj="onboarding--guideCard--view--search" fill={true} + isLoading={false} onClick={[Function]} size="m" > diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx index 2a777e9bdec4c..9646870782ff2 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { css } from '@emotion/react'; import { EuiButton, EuiProgress, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -50,7 +50,7 @@ export interface GuideCardFooterProps { guides: GuideState[]; useCase: GuideCardUseCase; telemetryId: string; - activateGuide: (useCase: GuideCardUseCase, guideState?: GuideState) => void; + activateGuide: (useCase: GuideCardUseCase, guideState?: GuideState) => Promise; } export const GuideCardFooter = ({ guides, @@ -59,14 +59,21 @@ export const GuideCardFooter = ({ activateGuide, }: GuideCardFooterProps) => { const guideState = guides.find((guide) => guide.guideId === (useCase as GuideId)); + const [isLoading, setIsLoading] = useState(false); + const activateGuideCallback = useCallback(async () => { + setIsLoading(true); + await activateGuide(useCase, guideState); + setIsLoading(false); + }, [activateGuide, guideState, useCase]); const viewGuideButton = ( activateGuide(useCase, guideState)} + onClick={activateGuideCallback} > {viewGuideLabel} @@ -122,10 +129,11 @@ export const GuideCardFooter = ({ activateGuide(useCase, guideState)} + onClick={activateGuideCallback} > {continueGuideLabel} diff --git a/src/plugins/guided_onboarding/public/components/guide_button.tsx b/src/plugins/guided_onboarding/public/components/guide_button.tsx index 7bff376c5af4b..16c51522bedb1 100644 --- a/src/plugins/guided_onboarding/public/components/guide_button.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_button.tsx @@ -20,12 +20,13 @@ interface GuideButtonProps { toggleGuidePanel: () => void; isGuidePanelOpen: boolean; navigateToLandingPage: () => void; + isLoading: boolean; } -const getStepNumber = (state: GuideState): number | undefined => { +const getStepNumber = (state?: GuideState): number | undefined => { let stepNumber: number | undefined; - state.steps.forEach((step, stepIndex) => { + 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; @@ -46,43 +47,15 @@ export const GuideButton = ({ toggleGuidePanel, isGuidePanelOpen, navigateToLandingPage, + isLoading, }: GuideButtonProps) => { - // TODO handle loading state - // https://github.com/elastic/kibana/issues/139799 - - // if there is no active guide - if (!pluginState || !pluginState.activeGuide || !pluginState.activeGuide.isActive) { - // if still active period and the user has not started a guide or skipped the guide, - // display the button that redirects to the landing page - if ( - !( - pluginState?.isActivePeriod && - (pluginState?.status === 'not_started' || pluginState?.status === 'skipped') - ) - ) { - return null; - } else { - return ( - - {i18n.translate('guidedOnboarding.guidedSetupRedirectButtonLabel', { - defaultMessage: 'Setup guides', - })} - - ); - } - } - const stepNumber = getStepNumber(pluginState.activeGuide); - const stepReadyToComplete = pluginState.activeGuide.steps.find( + const stepNumber = getStepNumber(pluginState?.activeGuide); + const stepReadyToComplete = pluginState?.activeGuide?.steps.find( (step) => step.status === 'ready_to_complete' ); const button = ( ); + // if there is no active guide + if (!pluginState || !pluginState.activeGuide || !pluginState.activeGuide.isActive) { + // if still active period and the user has not started a guide or skipped the guide, + // display the button that redirects to the landing page + if ( + pluginState?.isActivePeriod && + (pluginState?.status === 'not_started' || pluginState?.status === 'skipped') + ) { + return ( + + {i18n.translate('guidedOnboarding.guidedSetupRedirectButtonLabel', { + defaultMessage: 'Setup guides', + })} + + ); + } + // if error state, display the header button (error section is in the dropdown panel) + if (pluginState?.status === 'error') { + return button; + } + // otherwise hide the button (the guide is completed, quit, skipped or not started) + return null; + } + if (stepReadyToComplete) { const stepConfig = guideConfig?.steps.find((step) => step.id === stepReadyToComplete.id); // check if the stepConfig has manualCompletion info diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx index 708cc27567857..2fe236a7d999d 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx @@ -27,6 +27,7 @@ import { testGuideStep3ActiveState, readyToCompleteGuideState, mockPluginStateNotStarted, + mockPluginStateInProgress, } from '../services/api.mocks'; import { GuidePanel } from './guide_panel'; @@ -82,11 +83,10 @@ describe('Guided setup', () => { describe('Button component', () => { describe('when a guide is active', () => { it('button is enabled', async () => { - const { exists, find } = await setupComponentWithPluginStateMock(httpClient, { - status: 'in_progress', - isActivePeriod: true, - activeGuide: testGuideStep1ActiveState, - }); + const { exists, find } = await setupComponentWithPluginStateMock( + httpClient, + mockPluginStateInProgress + ); expect(exists('guideButton')).toBe(true); expect(find('guideButton').text()).toEqual('Setup guide'); expect(exists('guideButtonRedirect')).toBe(false); @@ -225,16 +225,26 @@ describe('Guided setup', () => { expect(exists('guideButton')).toBe(false); }); }); + + describe('when there is an error', function () { + test('displays the header button that toggles the panel', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'error', + isActivePeriod: false, + }); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(true); + }); + }); }); }); describe('Panel component', () => { test('if a guide is active, the button click opens the panel', async () => { - const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { - status: 'in_progress', - isActivePeriod: true, - activeGuide: testGuideStep1ActiveState, - }); + const { exists, find, component } = await setupComponentWithPluginStateMock( + httpClient, + mockPluginStateInProgress + ); find('guideButton').simulate('click'); component.update(); @@ -352,11 +362,7 @@ describe('Guided setup', () => { activeGuide: testGuideStep1InProgressState, }, }); - testBed = await setupComponentWithPluginStateMock(httpClient, { - status: 'in_progress', - isActivePeriod: true, - activeGuide: testGuideStep1ActiveState, - }); + testBed = await setupComponentWithPluginStateMock(httpClient, mockPluginStateInProgress); const { exists, find, component } = testBed; find('guideButton').simulate('click'); component.update(); @@ -436,11 +442,10 @@ describe('Guided setup', () => { }); test('renders the step description list as an unordered list', async () => { - const { find, component } = await setupComponentWithPluginStateMock(httpClient, { - status: 'in_progress', - isActivePeriod: true, - activeGuide: testGuideStep1ActiveState, - }); + const { find, component } = await setupComponentWithPluginStateMock( + httpClient, + mockPluginStateInProgress + ); find('guideButton').simulate('click'); component.update(); @@ -478,11 +483,7 @@ describe('Guided setup', () => { isActivePeriod: true, }, }); - testBed = await setupComponentWithPluginStateMock(httpClient, { - status: 'in_progress', - isActivePeriod: true, - activeGuide: testGuideStep1ActiveState, - }); + testBed = await setupComponentWithPluginStateMock(httpClient, mockPluginStateInProgress); const { find, component, exists } = testBed; find('guideButton').simulate('click'); @@ -522,5 +523,34 @@ describe('Guided setup', () => { expect(exists('guideButton')).toBe(true); }); }); + + describe('error state', () => { + it('plugin state is error', async () => { + const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'error', + isActivePeriod: false, + }); + find('guideButton').simulate('click'); + component.update(); + expect(exists('guideErrorSection')).toBe(true); + }); + const mockGuideConfigNotFound = (path: string, pluginState: PluginState) => { + if (path === `${API_BASE_PATH}/configs/${testGuideId}`) { + return Promise.reject('not found'); + } + return Promise.resolve({ pluginState }); + }; + it('guide is active but no guide config', async () => { + httpClient.get.mockImplementation((path) => + mockGuideConfigNotFound(path as unknown as string, mockPluginStateInProgress) + ); + apiService.setup(httpClient, true); + const { exists, find, component } = await setupGuidePanelComponent(apiService); + find('guideButton').simulate('click'); + component.update(); + + expect(exists('guideErrorSection')).toBe(true); + }); + }); }); }); diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.tsx index 608948eecd5a6..7ba811fec503c 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.tsx @@ -24,6 +24,7 @@ import { EuiFlexGroup, EuiFlexItem, useEuiTheme, + EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -59,12 +60,48 @@ const getProgress = (state?: GuideState): number => { return 0; }; +const errorSection = ( + + {i18n.translate('guidedOnboarding.dropdownPanel.errorSectionTitle', { + defaultMessage: 'Unable to load the guide', + })} + + } + body={ + <> + + {i18n.translate('guidedOnboarding.dropdownPanel.errorSectionDescription', { + defaultMessage: `Wait a moment and try again. If the problem persists, contact your administrator.`, + })} + + + window.location.reload()} + iconType="refresh" + color="danger" + > + {i18n.translate('guidedOnboarding.dropdownPanel.errorSectionReloadButton', { + defaultMessage: 'Reload', + })} + + + } + /> +); + export const GuidePanel = ({ api, application, notifications }: GuidePanelProps) => { const { euiTheme } = useEuiTheme(); const [isGuideOpen, setIsGuideOpen] = useState(false); const [isQuitGuideModalOpen, setIsQuitGuideModalOpen] = useState(false); const [pluginState, setPluginState] = useState(undefined); const [guideConfig, setGuideConfig] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); const styles = getGuidePanelStyles(euiTheme); @@ -72,39 +109,42 @@ export const GuidePanel = ({ api, application, notifications }: GuidePanelProps) setIsGuideOpen((prevIsGuideOpen) => !prevIsGuideOpen); }; - const handleStepButtonClick = async (step: GuideStepStatus, stepConfig: StepConfig) => { - if (pluginState) { - const { id, status } = step; - const guideId: GuideId = pluginState!.activeGuide!.guideId!; + const handleStepButtonClick = useCallback( + async (step: GuideStepStatus, stepConfig: StepConfig) => { + if (pluginState) { + const { id, status } = step; + const guideId: GuideId = pluginState!.activeGuide!.guideId!; - try { - if (status === 'ready_to_complete') { - return await api.completeGuideStep(guideId, id); - } + try { + if (status === 'ready_to_complete') { + return await api.completeGuideStep(guideId, id); + } - if (status === 'active' || status === 'in_progress') { - await api.startGuideStep(guideId, id); + if (status === 'active' || status === 'in_progress') { + await api.startGuideStep(guideId, id); - if (stepConfig.location) { - await application.navigateToApp(stepConfig.location.appID, { - path: stepConfig.location.path, - }); + if (stepConfig.location) { + await application.navigateToApp(stepConfig.location.appID, { + path: stepConfig.location.path, + }); - if (stepConfig.manualCompletion?.readyToCompleteOnNavigation) { - await api.completeGuideStep(guideId, id); + if (stepConfig.manualCompletion?.readyToCompleteOnNavigation) { + await api.completeGuideStep(guideId, id); + } } } + } catch (error) { + notifications.toasts.addDanger({ + title: i18n.translate('guidedOnboarding.dropdownPanel.stepHandlerError', { + defaultMessage: 'Unable to update the guide. Wait a moment and try again.', + }), + text: error.message, + }); } - } catch (error) { - notifications.toasts.addDanger({ - title: i18n.translate('guidedOnboarding.dropdownPanel.stepHandlerError', { - defaultMessage: 'Unable to update the guide. Wait a moment and try again.', - }), - text: error.message, - }); } - } - }; + }, + [api, application, notifications.toasts, pluginState] + ); const navigateToLandingPage = () => { setIsGuideOpen(false); @@ -149,6 +189,13 @@ export const GuidePanel = ({ api, application, notifications }: GuidePanelProps) return () => subscription.unsubscribe(); }, [api]); + useEffect(() => { + const subscription = api.isLoading$.subscribe((isLoadingValue) => { + setIsLoading(isLoadingValue); + }); + return () => subscription.unsubscribe(); + }, [api]); + useEffect(() => { const subscription = api.isGuidePanelOpen$.subscribe((isGuidePanelOpen) => { setIsGuideOpen(isGuidePanelOpen); @@ -159,7 +206,9 @@ export const GuidePanel = ({ api, application, notifications }: GuidePanelProps) const fetchGuideConfig = useCallback(async () => { if (pluginState?.activeGuide?.guideId) { const config = await api.getGuideConfig(pluginState.activeGuide.guideId); - if (config) setGuideConfig(config); + if (config) { + setGuideConfig(config); + } } }, [api, pluginState]); @@ -167,16 +216,27 @@ export const GuidePanel = ({ api, application, notifications }: GuidePanelProps) fetchGuideConfig(); }, [fetchGuideConfig]); - // TODO handle loading state - // https://github.com/elastic/kibana/issues/139799 - const stepsCompleted = getProgress(pluginState?.activeGuide); const isGuideReadyToComplete = pluginState?.activeGuide?.status === 'ready_to_complete'; + const backToGuidesButton = ( + + {i18n.translate('guidedOnboarding.dropdownPanel.backToGuidesLink', { + defaultMessage: 'Back to guides', + })} + + ); return ( <>
- {isGuideOpen && guideConfig && ( + {isGuideOpen && ( - - - {i18n.translate('guidedOnboarding.dropdownPanel.backToGuidesLink', { - defaultMessage: 'Back to guides', - })} - - - -

- {isGuideReadyToComplete - ? i18n.translate('guidedOnboarding.dropdownPanel.completeGuideFlyoutTitle', { - defaultMessage: 'Well done!', - }) - : guideConfig.title} -

-
- - - -
- - -
- -

- {isGuideReadyToComplete - ? i18n.translate( - 'guidedOnboarding.dropdownPanel.completeGuideFlyoutDescription', - { - defaultMessage: `You've completed the Elastic {guideName} guide.`, - values: { - guideName: guideConfig.guideName, - }, - } - ) - : guideConfig.description} -

-
- - {guideConfig.docs && ( - <> - + {guideConfig && pluginState && pluginState.status !== 'error' ? ( + <> + + {backToGuidesButton} + +

+ {isGuideReadyToComplete + ? i18n.translate('guidedOnboarding.dropdownPanel.completeGuideFlyoutTitle', { + defaultMessage: 'Well done!', + }) + : guideConfig.title} +

+
+ + + +
+ + +
- - {guideConfig.docs.text} - +

+ {isGuideReadyToComplete + ? i18n.translate( + 'guidedOnboarding.dropdownPanel.completeGuideFlyoutDescription', + { + defaultMessage: `You've completed the Elastic {guideName} guide.`, + values: { + guideName: guideConfig.guideName, + }, + } + ) + : guideConfig.description} +

- - )} - - {/* Progress bar should only show after the first step has been complete */} - {stepsCompleted > 0 && ( - <> - - + + + + {guideConfig.docs.text} + + + + )} + + {/* Progress bar should only show after the first step has been complete */} + {stepsCompleted > 0 && ( + <> + + + + + + )} + + + + {guideConfig?.steps.map((step, index) => { + const accordionId = htmlIdGenerator(`accordion${index}`)(); + const stepState = pluginState?.activeGuide?.steps[index]; + + if (stepState) { + return ( + handleStepButtonClick(stepState, step)} + key={accordionId} + telemetryGuideId={guideConfig!.telemetryId} + /> + ); } - value={stepsCompleted} - valueText={i18n.translate('guidedOnboarding.dropdownPanel.progressValueLabel', { - defaultMessage: '{stepCount} steps', - values: { - stepCount: `${stepsCompleted} / ${guideConfig.steps.length}`, - }, - })} - max={guideConfig.steps.length} - size="l" - /> - - - - )} - - - - {guideConfig?.steps.map((step, index) => { - const accordionId = htmlIdGenerator(`accordion${index}`)(); - const stepState = pluginState?.activeGuide?.steps[index]; - - if (stepState) { - return ( - handleStepButtonClick(stepState, step)} - key={accordionId} - telemetryGuideId={guideConfig!.telemetryId} - /> - ); - } - })} - - {isGuideReadyToComplete && ( - + })} + + {isGuideReadyToComplete && ( + + + completeGuide(guideConfig.completedGuideRedirectLocation)} + fill + // data-test-subj used for FS tracking and testing + data-test-subj={`onboarding--completeGuideButton--${ + guideConfig!.telemetryId + }`} + > + {i18n.translate('guidedOnboarding.dropdownPanel.elasticButtonLabel', { + defaultMessage: 'Continue using Elastic', + })} + + + + )} +
+
+ + + - completeGuide(guideConfig.completedGuideRedirectLocation)} - fill - // data-test-subj used for FS tracking and testing - data-test-subj={`onboarding--completeGuideButton--${ - guideConfig!.telemetryId - }`} + - {i18n.translate('guidedOnboarding.dropdownPanel.elasticButtonLabel', { - defaultMessage: 'Continue using Elastic', + {i18n.translate('guidedOnboarding.dropdownPanel.footer.support', { + defaultMessage: 'Need help?', })} - + + + + + | + + + + + {i18n.translate('guidedOnboarding.dropdownPanel.footer.feedback', { + defaultMessage: 'Give feedback', + })} + + + + + | + + + + + {i18n.translate( + 'guidedOnboarding.dropdownPanel.footer.exitGuideButtonLabel', + { + defaultMessage: 'Quit guide', + } + )} + - )} -
-
- - - - - - {i18n.translate('guidedOnboarding.dropdownPanel.footer.support', { - defaultMessage: 'Need help?', - })} - - - - - | - - - - - {i18n.translate('guidedOnboarding.dropdownPanel.footer.feedback', { - defaultMessage: 'Give feedback', - })} - - - - - | - - - - - {i18n.translate('guidedOnboarding.dropdownPanel.footer.exitGuideButtonLabel', { - defaultMessage: 'Quit guide', - })} - - - - + + + ) : ( + + {backToGuidesButton} + {errorSection} + + )}
)} diff --git a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx index f916b43642722..ba3cedae12e0e 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel_step.tsx @@ -33,6 +33,7 @@ interface GuideStepProps { stepNumber: number; handleButtonClick: () => void; telemetryGuideId: string; + isLoading: boolean; } const renderDescription = (description: string | StepDescriptionWithLink) => { @@ -60,6 +61,7 @@ export const GuideStep = ({ stepConfig, handleButtonClick, telemetryGuideId, + isLoading, }: GuideStepProps) => { const { euiTheme } = useEuiTheme(); const styles = getGuidePanelStepStyles(euiTheme, stepStatus); @@ -130,7 +132,8 @@ export const GuideStep = ({ handleButtonClick()} + isLoading={isLoading} + onClick={handleButtonClick} fill // data-test-subj used for FS tracking and tests data-test-subj={`onboarding--stepButton--${telemetryGuideId}--step${stepNumber}`} diff --git a/src/plugins/guided_onboarding/public/mocks.ts b/src/plugins/guided_onboarding/public/mocks.ts index f463d0c4ca858..e190ddbcc219e 100644 --- a/src/plugins/guided_onboarding/public/mocks.ts +++ b/src/plugins/guided_onboarding/public/mocks.ts @@ -26,6 +26,7 @@ const apiServiceMock: jest.Mocked = { completeGuidedOnboardingForIntegration: jest.fn(), skipGuidedOnboarding: jest.fn(), isGuidePanelOpen$: new BehaviorSubject(false), + isLoading$: new BehaviorSubject(false), getGuideConfig: jest.fn(), }, }; diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index 048fcec8eab98..23a2343f6a6e7 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -37,6 +37,12 @@ describe('GuidedOnboarding ApiService', () => { let subscription: Subscription; let anotherSubscription: Subscription; + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); beforeEach(() => { httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); httpClient.get.mockResolvedValue({ @@ -74,7 +80,7 @@ describe('GuidedOnboarding ApiService', () => { httpClient.get.mockRejectedValueOnce(new Error('request failed')); subscription = apiService.fetchPluginState$().subscribe(); // wait until the request fails - await new Promise((resolve) => process.nextTick(resolve)); + jest.runAllTimers(); anotherSubscription = apiService.fetchPluginState$().subscribe(); expect(httpClient.get).toHaveBeenCalledTimes(1); }); @@ -565,6 +571,164 @@ describe('GuidedOnboarding ApiService', () => { }); }); + describe('isLoading$', () => { + it('is false by default', () => { + const isLoading = apiService.isLoading$.value; + expect(isLoading).toBe(false); + }); + + const testRequest = async (isFailedRequest?: boolean) => { + // advance the time to "while" the request is in flight + jest.advanceTimersByTime(1000); + expect(apiService.isLoading$.value).toBe(true); + + // advance the time to "after" the request has completed + jest.runAllTimers(); + if (isFailedRequest) { + // next tick to allow the code in the "catch" clause to run + await Promise.reject().catch(() => {}); + } + // next tick to allow the code in the "then" clause to run + await Promise.resolve().then(() => {}); + expect(apiService.isLoading$.value).toBe(false); + }; + + describe('is updated when fetching plugin state', () => { + it('true while request is in flight, false after the request completes', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve) => + setTimeout( + () => + resolve({ + pluginState: mockPluginStateNotStarted, + }), + 2000 + ) + ); + }); + apiService.setup(httpClient, true); + // starting the request + subscription = apiService.fetchPluginState$().subscribe(); + await testRequest(); + }); + + it('true while request is in flight, false after the request fails', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve, reject) => + setTimeout(() => reject(new Error('test')), 2000) + ); + }); + apiService.setup(httpClient, true); + // starting the request + subscription = apiService.fetchPluginState$().subscribe(); + + await testRequest(true); + }); + }); + + describe('is updated when fetching all guides state', () => { + it('true while request is in flight, false after the request completes', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve) => + setTimeout( + () => + resolve({ + state: [], + }), + 2000 + ) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.fetchAllGuidesState().then(); + + await testRequest(); + }); + + it('true while request is in flight, false after the request fails', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve, reject) => + setTimeout(() => reject(new Error('test')), 2000) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.fetchAllGuidesState().catch(() => {}); + + await testRequest(true); + }); + }); + + describe('is updated when updating guide state', () => { + it('true while request is in flight, false after the request completes', async () => { + httpClient.put.mockImplementation(() => { + return new Promise((resolve) => + setTimeout( + () => + resolve({ + pluginState: mockPluginStateNotStarted, + }), + 2000 + ) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.updatePluginState({}, true).then(); + + await testRequest(); + }); + + it('true while request is in flight, false after the request fails', async () => { + httpClient.put.mockImplementation(() => { + return new Promise((resolve, reject) => + setTimeout(() => reject(new Error('test')), 2000) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.updatePluginState({}, true).catch(() => {}); + + await testRequest(true); + }); + }); + + describe('is updated when fetching guide config', () => { + it('true while request is in flight, false after the request completes', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve) => + setTimeout( + () => + resolve({ + config: testGuideConfig, + }), + 2000 + ) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.getGuideConfig(testGuideId).then(() => {}); + + await testRequest(); + }); + + it('true while request is in flight, false after the request fails', async () => { + httpClient.get.mockImplementation(() => { + return new Promise((resolve, reject) => + setTimeout(() => reject(new Error('test')), 2000) + ); + }); + apiService.setup(httpClient, true); + // starting the request + apiService.getGuideConfig(testGuideId).catch(() => {}); + + await testRequest(true); + }); + }); + }); + describe('getGuideConfig', () => { it('sends a request to the get config API', async () => { apiService.setup(httpClient, true); diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts index a43a2fac23cda..d6ff80cf680b2 100644 --- a/src/plugins/guided_onboarding/public/services/api.ts +++ b/src/plugins/guided_onboarding/public/services/api.ts @@ -37,7 +37,7 @@ export class ApiService implements GuidedOnboardingApi { private isCloudEnabled: boolean | undefined; private client: HttpSetup | undefined; private pluginState$!: BehaviorSubject; - private isPluginStateLoading: boolean | undefined; + public isLoading$ = new BehaviorSubject(false); public isGuidePanelOpen$: BehaviorSubject = new BehaviorSubject(false); private configService = new ConfigService(); @@ -46,6 +46,7 @@ export class ApiService implements GuidedOnboardingApi { this.client = httpClient; this.pluginState$ = new BehaviorSubject(undefined); this.isGuidePanelOpen$ = new BehaviorSubject(false); + this.isLoading$ = new BehaviorSubject(false); this.configService.setup(httpClient); } @@ -53,18 +54,18 @@ export class ApiService implements GuidedOnboardingApi { return new Observable((observer) => { const controller = new AbortController(); const signal = controller.signal; - this.isPluginStateLoading = true; + this.isLoading$.next(true); this.client!.get<{ pluginState: PluginState }>(`${API_BASE_PATH}/state`, { signal, }) .then(({ pluginState }) => { - this.isPluginStateLoading = false; + this.isLoading$.next(false); observer.next(pluginState); this.pluginState$.next(pluginState); observer.complete(); }) .catch((error) => { - this.isPluginStateLoading = false; + this.isLoading$.next(false); // if the request fails, we initialize the state with error observer.next({ status: 'error', isActivePeriod: false }); this.pluginState$.next({ @@ -74,7 +75,7 @@ export class ApiService implements GuidedOnboardingApi { observer.complete(); }); return () => { - this.isPluginStateLoading = false; + this.isLoading$.next(false); controller.abort(); }; }); @@ -97,8 +98,8 @@ export class ApiService implements GuidedOnboardingApi { // if currentState is undefined, it was not fetched from the backend yet // or the request was cancelled or failed // also check if we don't have a request in flight already - if (!currentState && !this.isPluginStateLoading) { - this.isPluginStateLoading = true; + if (!currentState && !this.isLoading$.value) { + this.isLoading$.next(true); return concat(this.createGetPluginStateObservable(), this.pluginState$); } return this.pluginState$; @@ -118,8 +119,12 @@ export class ApiService implements GuidedOnboardingApi { } try { - return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/guides`); + this.isLoading$.next(true); + const response = await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/guides`); + this.isLoading$.next(false); + return response; } catch (error) { + this.isLoading$.next(false); throw error; } } @@ -143,17 +148,20 @@ export class ApiService implements GuidedOnboardingApi { } try { + this.isLoading$.next(true); const response = await this.client.put<{ pluginState: PluginState }>( `${API_BASE_PATH}/state`, { body: JSON.stringify(state), } ); + this.isLoading$.next(false); // update the guide state in the plugin state observable this.pluginState$.next(response.pluginState); this.isGuidePanelOpen$.next(panelState); return response; } catch (error) { + this.isLoading$.next(false); throw error; } } @@ -460,7 +468,10 @@ export class ApiService implements GuidedOnboardingApi { if (!this.client) { throw new Error('ApiService has not be initialized.'); } - return await this.configService.getGuideConfig(guideId); + this.isLoading$.next(true); + const config = await this.configService.getGuideConfig(guideId); + this.isLoading$.next(false); + return config; } } diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts index 0c34b56e82284..493ff13c8249d 100755 --- a/src/plugins/guided_onboarding/public/types.ts +++ b/src/plugins/guided_onboarding/public/types.ts @@ -53,5 +53,6 @@ export interface GuidedOnboardingApi { ) => Promise<{ pluginState: PluginState } | undefined>; skipGuidedOnboarding: () => Promise<{ pluginState: PluginState } | undefined>; isGuidePanelOpen$: Observable; + isLoading$: Observable; getGuideConfig: (guideId: GuideId) => Promise; } diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx index a874331bd4cac..f7ebcbc42b88b 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx @@ -26,9 +26,8 @@ import { useHistory } from 'react-router-dom'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import type { GuideState, GuideId } from '@kbn/guided-onboarding'; +import type { GuideState, GuideId, GuideCardUseCase } from '@kbn/guided-onboarding'; import { GuideCard, InfrastructureLinkCard } from '@kbn/guided-onboarding'; -import type { GuideCardUseCase } from '@kbn/guided-onboarding'; import { getServices } from '../../kibana_services'; import { KEY_ENABLE_WELCOME } from '../home'; @@ -114,18 +113,21 @@ export const GettingStarted = () => { `; const isDarkTheme = uiSettings.get('theme:darkMode'); - const activateGuide = async (useCase: GuideCardUseCase, guideState?: GuideState) => { - try { - await guidedOnboardingService?.activateGuide(useCase as GuideId, guideState); - } catch (err) { - getServices().toastNotifications.addDanger({ - title: i18n.translate('home.guidedOnboarding.gettingStarted.activateGuide.errorMessage', { - defaultMessage: 'Unable to start the guide. Wait a moment and try again.', - }), - text: err.message, - }); - } - }; + const activateGuide = useCallback( + async (useCase: GuideCardUseCase, guideState?: GuideState) => { + try { + await guidedOnboardingService?.activateGuide(useCase as GuideId, guideState); + } catch (err) { + getServices().toastNotifications.addDanger({ + title: i18n.translate('home.guidedOnboarding.gettingStarted.activateGuide.errorMessage', { + defaultMessage: 'Unable to start the guide. Wait a moment and try again.', + }), + text: err.message, + }); + } + }, + [guidedOnboardingService] + ); if (isLoading) { return (