Skip to content

Commit

Permalink
[Stateful sidenav] Welcome tour (elastic#194926)
Browse files Browse the repository at this point in the history
(cherry picked from commit 8cceaee)

# Conflicts:
#	x-pack/plugins/spaces/common/constants.ts
  • Loading branch information
sebelga committed Oct 15, 2024
1 parent 68ca739 commit 5b94ac5
Show file tree
Hide file tree
Showing 14 changed files with 509 additions and 25 deletions.
5 changes: 5 additions & 0 deletions x-pack/plugins/spaces/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ export const SOLUTION_VIEW_CLASSIC = 'classic' as const;
export const FEATURE_PRIVILEGES_ALL = 'all' as const;
export const FEATURE_PRIVILEGES_READ = 'read' as const;
export const FEATURE_PRIVILEGES_CUSTOM = 'custom' as const;

/**
* The setting to control whether the Space Solution Tour is shown.
*/
export const SHOW_SPACE_SOLUTION_TOUR_SETTING = 'showSpaceSolutionTour';
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getSpacesFeatureDescription } from '../../constants';
interface Props {
id: string;
isLoading: boolean;
toggleSpaceSelector: () => void;
onClickManageSpaceBtn: () => void;
capabilities: Capabilities;
navigateToApp: ApplicationStart['navigateToApp'];
}
Expand All @@ -45,7 +45,7 @@ export const SpacesDescription: FC<Props> = (props: Props) => {
<ManageSpacesButton
size="s"
style={{ width: `100%` }}
onClick={props.toggleSpaceSelector}
onClick={props.onClickManageSpaceBtn}
capabilities={props.capabilities}
navigateToApp={props.navigateToApp}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface Props {
spaces: Space[];
serverBasePath: string;
toggleSpaceSelector: () => void;
onClickManageSpaceBtn: () => void;
intl: InjectedIntl;
capabilities: Capabilities;
navigateToApp: ApplicationStart['navigateToApp'];
Expand Down Expand Up @@ -218,7 +219,7 @@ class SpacesMenuUI extends Component<Props> {
key="manageSpacesButton"
className="spcMenu__manageButton"
size="s"
onClick={this.props.toggleSpaceSelector}
onClick={this.props.onClickManageSpaceBtn}
capabilities={this.props.capabilities}
navigateToApp={this.props.navigateToApp}
/>
Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/spaces/public/nav_control/nav_control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ReactDOM from 'react-dom';
import type { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';

import { initTour } from './solution_view_tour';
import type { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { SpacesManager } from '../spaces_manager';
Expand All @@ -22,6 +23,8 @@ export function initSpacesNavControl(
config: ConfigType,
eventTracker: EventTracker
) {
const { showTour$, onFinishTour } = initTour(core, spacesManager);

core.chrome.navControls.registerLeft({
order: 1000,
mount(targetDomElement: HTMLElement) {
Expand All @@ -47,6 +50,8 @@ export function initSpacesNavControl(
navigateToUrl={core.application.navigateToUrl}
allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker}
showTour$={showTour$}
onFinishTour={onFinishTour}
/>
</Suspense>
</KibanaRenderContextProvider>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import {
EuiFieldSearch,
EuiHeaderSectionItemButton,
EuiPopover,
EuiSelectable,
EuiSelectableListItem,
} from '@elastic/eui';
Expand All @@ -18,7 +17,7 @@ import * as Rx from 'rxjs';

import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';

import { NavControlPopover } from './nav_control_popover';
import { NavControlPopover, type Props as NavControlPopoverProps } from './nav_control_popover';
import type { Space } from '../../common';
import { EventTracker } from '../analytics';
import { SpaceAvatarInternal } from '../space_avatar/space_avatar_internal';
Expand Down Expand Up @@ -49,7 +48,12 @@ const reportEvent = jest.fn();
const eventTracker = new EventTracker({ reportEvent });

describe('NavControlPopover', () => {
async function setup(spaces: Space[], allowSolutionVisibility = false, activeSpace?: Space) {
async function setup(
spaces: Space[],
allowSolutionVisibility = false,
activeSpace?: Space,
props?: Partial<NavControlPopoverProps>
) {
const spacesManager = spacesManagerMock.create();
spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces);

Expand All @@ -68,6 +72,9 @@ describe('NavControlPopover', () => {
navigateToUrl={jest.fn()}
allowSolutionVisibility={allowSolutionVisibility}
eventTracker={eventTracker}
showTour$={Rx.of(false)}
onFinishTour={jest.fn()}
{...props}
/>
);

Expand All @@ -81,7 +88,7 @@ describe('NavControlPopover', () => {
it('renders without crashing', () => {
const spacesManager = spacesManagerMock.create();

const { baseElement } = render(
const { baseElement, queryByTestId } = render(
<NavControlPopover
spacesManager={spacesManager as unknown as SpacesManager}
serverBasePath={'/server-base-path'}
Expand All @@ -91,9 +98,12 @@ describe('NavControlPopover', () => {
navigateToUrl={jest.fn()}
allowSolutionVisibility={false}
eventTracker={eventTracker}
showTour$={Rx.of(false)}
onFinishTour={jest.fn()}
/>
);
expect(baseElement).toMatchSnapshot();
expect(queryByTestId('spaceSolutionTour')).toBeNull();
});

it('renders a SpaceAvatar with the active space', async () => {
Expand All @@ -117,6 +127,8 @@ describe('NavControlPopover', () => {
navigateToUrl={jest.fn()}
allowSolutionVisibility={false}
eventTracker={eventTracker}
showTour$={Rx.of(false)}
onFinishTour={jest.fn()}
/>
);

Expand Down Expand Up @@ -223,20 +235,29 @@ describe('NavControlPopover', () => {
});

it('can close its popover', async () => {
jest.useFakeTimers();
const wrapper = await setup(mockSpaces);

expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(false); // closed

// Open the popover
await act(async () => {
wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click');
});
wrapper.update();
expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true);
expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(true); // open

// Close the popover
await act(async () => {
wrapper.find(EuiPopover).props().closePopover();
wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click');
});
act(() => {
jest.runAllTimers();
});
wrapper.update();
expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(false); // closed

expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false);
jest.useRealTimers();
});

it('should render solution for spaces', async () => {
Expand Down Expand Up @@ -301,4 +322,42 @@ describe('NavControlPopover', () => {
space_id_prev: 'space-1',
});
});

it('should show the solution view tour', async () => {
jest.useFakeTimers(); // the underlying EUI tour component has a timeout that needs to be flushed for the test to pass

const spaces: Space[] = [
{
id: 'space-1',
name: 'Space-1',
disabledFeatures: [],
solution: 'es',
},
];

const activeSpace = spaces[0];
const showTour$ = new Rx.BehaviorSubject(true);
const onFinishTour = jest.fn().mockImplementation(() => {
showTour$.next(false);
});

const wrapper = await setup(spaces, true /** allowSolutionVisibility **/, activeSpace, {
showTour$,
onFinishTour,
});

expect(findTestSubject(wrapper, 'spaceSolutionTour').exists()).toBe(true);

act(() => {
findTestSubject(wrapper, 'closeTourBtn').simulate('click');
});
act(() => {
jest.runAllTimers();
});
wrapper.update();

expect(findTestSubject(wrapper, 'spaceSolutionTour').exists()).toBe(false);

jest.useRealTimers();
});
});
63 changes: 48 additions & 15 deletions x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import {
withEuiTheme,
} from '@elastic/eui';
import React, { Component, lazy, Suspense } from 'react';
import type { Subscription } from 'rxjs';
import type { Observable, Subscription } from 'rxjs';

import type { ApplicationStart, Capabilities } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';

import { SpacesDescription } from './components/spaces_description';
import { SpacesMenu } from './components/spaces_menu';
import { SolutionViewTour } from './solution_view_tour';
import type { Space } from '../../common';
import type { EventTracker } from '../analytics';
import { getSpaceAvatarComponent } from '../space_avatar';
Expand All @@ -30,7 +31,7 @@ const LazySpaceAvatar = lazy(() =>
getSpaceAvatarComponent().then((component) => ({ default: component }))
);

interface Props {
export interface Props {
spacesManager: SpacesManager;
anchorPosition: PopoverAnchorPosition;
capabilities: Capabilities;
Expand All @@ -40,19 +41,23 @@ interface Props {
theme: WithEuiThemeProps['theme'];
allowSolutionVisibility: boolean;
eventTracker: EventTracker;
showTour$: Observable<boolean>;
onFinishTour: () => void;
}

interface State {
showSpaceSelector: boolean;
loading: boolean;
activeSpace: Space | null;
spaces: Space[];
showTour: boolean;
}

const popoutContentId = 'headerSpacesMenuContent';

class NavControlPopoverUI extends Component<Props, State> {
private activeSpace$?: Subscription;
private showTour$Sub?: Subscription;

constructor(props: Props) {
super(props);
Expand All @@ -61,6 +66,7 @@ class NavControlPopoverUI extends Component<Props, State> {
loading: false,
activeSpace: null,
spaces: [],
showTour: false,
};
}

Expand All @@ -72,25 +78,37 @@ class NavControlPopoverUI extends Component<Props, State> {
});
},
});

this.showTour$Sub = this.props.showTour$.subscribe((showTour) => {
this.setState({ showTour });
});
}

public componentWillUnmount() {
this.activeSpace$?.unsubscribe();
this.showTour$Sub?.unsubscribe();
}

public render() {
const button = this.getActiveSpaceButton();
const { theme } = this.props;
const { activeSpace } = this.state;

const isTourOpen = Boolean(activeSpace) && this.state.showTour && !this.state.showSpaceSelector;

let element: React.ReactNode;
if (this.state.loading || this.state.spaces.length < 2) {
element = (
<SpacesDescription
id={popoutContentId}
isLoading={this.state.loading}
toggleSpaceSelector={this.toggleSpaceSelector}
capabilities={this.props.capabilities}
navigateToApp={this.props.navigateToApp}
onClickManageSpaceBtn={() => {
// No need to show the tour anymore, the user is taking action
this.props.onFinishTour();
this.toggleSpaceSelector();
}}
/>
);
} else {
Expand All @@ -106,24 +124,38 @@ class NavControlPopoverUI extends Component<Props, State> {
activeSpace={this.state.activeSpace}
allowSolutionVisibility={this.props.allowSolutionVisibility}
eventTracker={this.props.eventTracker}
onClickManageSpaceBtn={() => {
// No need to show the tour anymore, the user is taking action
this.props.onFinishTour();
this.toggleSpaceSelector();
}}
/>
);
}

return (
<EuiPopover
id="spcMenuPopover"
button={button}
isOpen={this.state.showSpaceSelector}
closePopover={this.closeSpaceSelector}
anchorPosition={this.props.anchorPosition}
panelPaddingSize="none"
repositionOnScroll
ownFocus
zIndex={Number(theme.euiTheme.levels.navigation) + 1} // it needs to sit above the collapsible nav menu
<SolutionViewTour
solution={activeSpace?.solution}
isTourOpen={isTourOpen}
onFinishTour={this.props.onFinishTour}
>
{element}
</EuiPopover>
<EuiPopover
id="spcMenuPopover"
button={button}
isOpen={this.state.showSpaceSelector}
closePopover={this.closeSpaceSelector}
anchorPosition={this.props.anchorPosition}
panelPaddingSize="none"
repositionOnScroll
ownFocus
zIndex={Number(theme.euiTheme.levels.navigation) + 1} // it needs to sit above the collapsible nav menu
panelProps={{
'data-test-subj': 'spaceMenuPopoverPanel',
}}
>
{element}
</EuiPopover>
</SolutionViewTour>
);
}

Expand Down Expand Up @@ -195,6 +227,7 @@ class NavControlPopoverUI extends Component<Props, State> {

protected toggleSpaceSelector = () => {
const isOpening = !this.state.showSpaceSelector;

if (isOpening) {
this.loadSpaces();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export { initTour } from './lib';

export { SolutionViewTour } from './solution_view_tour';
Loading

0 comments on commit 5b94ac5

Please sign in to comment.