Skip to content

Commit

Permalink
[Guided onboarding] Loading state/error handling (#147374)
Browse files Browse the repository at this point in the history
## Summary
Fixes #146437
Fixes #139799

This PR adds a loading state indicator to the landing page when a user
clicks the "view/continue guide" button. It also adds a loading
indicator to the header button and to the dropdown panel (step buttons).
This is implemented via a new observable in the api service `isLoading$`
which is being updated every time a request is sent to the guided
onboarding API. There is also a new error section in the panel that is
displayed when the plugin state is `error` or the guide config is not
found.

_Note for copy reviewers_: Could you please have a look at the new text
is in the screenshot "Error state in the panel"?

### Screenshots/screencasts
#### Landing page buttons


https://user-images.githubusercontent.com/6585477/207427764-a17cdc9a-1b85-4a47-aaa5-6fabc193c99e.mov

Note: the loading indicator blocks individual buttons, so the user could
click view one guide and before the request completes and the panel is
shown, they could click to view another guide. Instead we could consider
blocking all buttons at once on the landing page.

#### Header button and panel


https://user-images.githubusercontent.com/6585477/207428327-7465a5e3-a395-42d1-aa6b-32d339194a00.mov


#### Error state in the panel
<img width="535" alt="Screenshot 2022-12-15 at 15 11 04"
src="https://user-images.githubusercontent.com/6585477/207883802-09e44b62-cfb3-484a-9db8-3832b1683729.png">


Note: since the api service logic prevents it from sending multiple
requests once the plugin state or guide config has been initialized from
the backend, the user can't retry the request. That is why the button in
the error panel reloads the page.

### How to test
I recommend using [tweak](https://tweak-extension.com/) browser
extension to simulate errors in API requests. Here are 2 requests I'm
mocking for the tests:
<img width="778" alt="Screenshot 2022-12-13 at 20 54 33"
src="https://user-images.githubusercontent.com/6585477/207431207-03eb71c0-60b0-4d9f-ad30-0ecc092707ac.png">

<img width="782" alt="Screenshot 2022-12-13 at 20 52 02"
src="https://user-images.githubusercontent.com/6585477/207430965-7f2bb370-cc43-459e-ab14-bfc92ce504dd.png">


### Checklist


- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
yuliacech and kibanamachine authored Dec 15, 2022
1 parent efe53d8 commit e709523
Show file tree
Hide file tree
Showing 11 changed files with 594 additions and 300 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;
}
export const GuideCardFooter = ({
guides,
Expand All @@ -59,14 +59,21 @@ export const GuideCardFooter = ({
activateGuide,
}: GuideCardFooterProps) => {
const guideState = guides.find((guide) => guide.guideId === (useCase as GuideId));
const [isLoading, setIsLoading] = useState<boolean>(false);
const activateGuideCallback = useCallback(async () => {
setIsLoading(true);
await activateGuide(useCase, guideState);
setIsLoading(false);
}, [activateGuide, guideState, useCase]);
const viewGuideButton = (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButton
isLoading={isLoading}
// Used for FS tracking
data-test-subj={`onboarding--guideCard--view--${telemetryId}`}
fill
onClick={() => activateGuide(useCase, guideState)}
onClick={activateGuideCallback}
>
{viewGuideLabel}
</EuiButton>
Expand Down Expand Up @@ -122,10 +129,11 @@ export const GuideCardFooter = ({
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButton
isLoading={isLoading}
// Used for FS tracking
data-test-subj={`onboarding--guideCard--continue--${telemetryId}`}
fill
onClick={() => activateGuide(useCase, guideState)}
onClick={activateGuideCallback}
>
{continueGuideLabel}
</EuiButton>
Expand Down
71 changes: 37 additions & 34 deletions src/plugins/guided_onboarding/public/components/guide_button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<EuiButton
onClick={navigateToLandingPage}
color="success"
fill
size="s"
data-test-subj="guideButtonRedirect"
>
{i18n.translate('guidedOnboarding.guidedSetupRedirectButtonLabel', {
defaultMessage: 'Setup guides',
})}
</EuiButton>
);
}
}
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 = (
<EuiButton
isLoading={isLoading}
onClick={toggleGuidePanel}
color="success"
fill
Expand All @@ -101,6 +74,36 @@ export const GuideButton = ({
})}
</EuiButton>
);
// 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 (
<EuiButton
onClick={navigateToLandingPage}
color="success"
fill
size="s"
data-test-subj="guideButtonRedirect"
>
{i18n.translate('guidedOnboarding.guidedSetupRedirectButtonLabel', {
defaultMessage: 'Setup guides',
})}
</EuiButton>
);
}
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
testGuideStep3ActiveState,
readyToCompleteGuideState,
mockPluginStateNotStarted,
mockPluginStateInProgress,
} from '../services/api.mocks';
import { GuidePanel } from './guide_panel';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
});
});
});
Loading

0 comments on commit e709523

Please sign in to comment.