mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[Stateful sidenav] Welcome tour (#194926)](https://github.com/elastic/kibana/pull/194926) <!--- Backport version: 8.9.8 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Sébastien Loix","email":"sebastien.loix@elastic.co"},"sourceCommit":{"committedDate":"2024-10-15T12:18:30Z","message":"[Stateful sidenav] Welcome tour (#194926)","sha":"8cceaee0f42c6c0e7ee064ef98a0e652fd77e286","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Security/Spaces","release_note:skip","v9.0.0","Team:SharedUX","backport:prev-minor"],"number":194926,"url":"https://github.com/elastic/kibana/pull/194926","mergeCommit":{"message":"[Stateful sidenav] Welcome tour (#194926)","sha":"8cceaee0f42c6c0e7ee064ef98a0e652fd77e286"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194926","number":194926,"mergeCommit":{"message":"[Stateful sidenav] Welcome tour (#194926)","sha":"8cceaee0f42c6c0e7ee064ef98a0e652fd77e286"}}]}] BACKPORT-->
This commit is contained in:
parent
ebfc4d93ea
commit
db2adf7588
14 changed files with 509 additions and 25 deletions
|
@ -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';
|
||||
|
|
|
@ -20,7 +20,7 @@ import { getSpacesFeatureDescription } from '../../constants';
|
|||
interface Props {
|
||||
id: string;
|
||||
isLoading: boolean;
|
||||
toggleSpaceSelector: () => void;
|
||||
onClickManageSpaceBtn: () => void;
|
||||
capabilities: Capabilities;
|
||||
navigateToApp: ApplicationStart['navigateToApp'];
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -43,6 +43,7 @@ interface Props {
|
|||
spaces: Space[];
|
||||
serverBasePath: string;
|
||||
toggleSpaceSelector: () => void;
|
||||
onClickManageSpaceBtn: () => void;
|
||||
intl: InjectedIntl;
|
||||
capabilities: Capabilities;
|
||||
navigateToApp: ApplicationStart['navigateToApp'];
|
||||
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
@ -47,6 +50,8 @@ export function initSpacesNavControl(
|
|||
navigateToUrl={core.application.navigateToUrl}
|
||||
allowSolutionVisibility={config.allowSolutionVisibility}
|
||||
eventTracker={eventTracker}
|
||||
showTour$={showTour$}
|
||||
onFinishTour={onFinishTour}
|
||||
/>
|
||||
</Suspense>
|
||||
</KibanaRenderContextProvider>,
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import {
|
||||
EuiFieldSearch,
|
||||
EuiHeaderSectionItemButton,
|
||||
EuiPopover,
|
||||
EuiSelectable,
|
||||
EuiSelectableListItem,
|
||||
} from '@elastic/eui';
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
||||
|
@ -68,6 +72,9 @@ describe('NavControlPopover', () => {
|
|||
navigateToUrl={jest.fn()}
|
||||
allowSolutionVisibility={allowSolutionVisibility}
|
||||
eventTracker={eventTracker}
|
||||
showTour$={Rx.of(false)}
|
||||
onFinishTour={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -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'}
|
||||
|
@ -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 () => {
|
||||
|
@ -117,6 +127,8 @@ describe('NavControlPopover', () => {
|
|||
navigateToUrl={jest.fn()}
|
||||
allowSolutionVisibility={false}
|
||||
eventTracker={eventTracker}
|
||||
showTour$={Rx.of(false)}
|
||||
onFinishTour={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -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 () => {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
@ -30,7 +31,7 @@ const LazySpaceAvatar = lazy(() =>
|
|||
getSpaceAvatarComponent().then((component) => ({ default: component }))
|
||||
);
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
spacesManager: SpacesManager;
|
||||
anchorPosition: PopoverAnchorPosition;
|
||||
capabilities: Capabilities;
|
||||
|
@ -40,6 +41,8 @@ interface Props {
|
|||
theme: WithEuiThemeProps['theme'];
|
||||
allowSolutionVisibility: boolean;
|
||||
eventTracker: EventTracker;
|
||||
showTour$: Observable<boolean>;
|
||||
onFinishTour: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
@ -47,12 +50,14 @@ interface State {
|
|||
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);
|
||||
|
@ -61,6 +66,7 @@ class NavControlPopoverUI extends Component<Props, State> {
|
|||
loading: false,
|
||||
activeSpace: null,
|
||||
spaces: [],
|
||||
showTour: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -72,15 +78,23 @@ 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) {
|
||||
|
@ -88,9 +102,13 @@ class NavControlPopoverUI extends Component<Props, State> {
|
|||
<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 {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -195,6 +227,7 @@ class NavControlPopoverUI extends Component<Props, State> {
|
|||
|
||||
protected toggleSpaceSelector = () => {
|
||||
const isOpening = !this.state.showSpaceSelector;
|
||||
|
||||
if (isOpening) {
|
||||
this.loadSpaces();
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, defer, from, map, of, shareReplay, switchMap } from 'rxjs';
|
||||
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
|
||||
import type { Space } from '../../../common';
|
||||
import {
|
||||
DEFAULT_SPACE_ID,
|
||||
SHOW_SPACE_SOLUTION_TOUR_SETTING,
|
||||
SOLUTION_VIEW_CLASSIC,
|
||||
} from '../../../common/constants';
|
||||
import type { SpacesManager } from '../../spaces_manager';
|
||||
|
||||
export function initTour(core: CoreStart, spacesManager: SpacesManager) {
|
||||
const showTourUiSettingValue = core.settings.globalClient.get(SHOW_SPACE_SOLUTION_TOUR_SETTING);
|
||||
const showTour$ = new BehaviorSubject(showTourUiSettingValue ?? true);
|
||||
|
||||
const allSpaces$ = defer(() => from(spacesManager.getSpaces())).pipe(shareReplay(1));
|
||||
|
||||
const hasMultipleSpaces = (spaces: Space[]) => {
|
||||
return spaces.length > 1;
|
||||
};
|
||||
|
||||
const isDefaultSpaceOnClassic = (spaces: Space[]) => {
|
||||
const defaultSpace = spaces.find((space) => space.id === DEFAULT_SPACE_ID);
|
||||
|
||||
if (!defaultSpace) {
|
||||
// Don't show the tour if the default space doesn't exist (this should never happen)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!defaultSpace.solution || defaultSpace.solution === SOLUTION_VIEW_CLASSIC) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const showTourObservable$ = showTour$.pipe(
|
||||
switchMap((showTour) => {
|
||||
if (!showTour) return of(false);
|
||||
|
||||
return allSpaces$.pipe(
|
||||
map((spaces) => {
|
||||
if (hasMultipleSpaces(spaces) || isDefaultSpaceOnClassic(spaces)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const hideTourInGlobalSettings = () => {
|
||||
core.settings.globalClient.set(SHOW_SPACE_SOLUTION_TOUR_SETTING, false).catch(() => {
|
||||
// Silently swallow errors, the user will just see the tour again next time they load the page
|
||||
});
|
||||
};
|
||||
|
||||
if (showTourUiSettingValue !== false) {
|
||||
allSpaces$.subscribe((spaces) => {
|
||||
if (hasMultipleSpaces(spaces) || isDefaultSpaceOnClassic(spaces)) {
|
||||
// If we have either (1) multiple space or (2) only one space and it's the default space with the classic solution,
|
||||
// we don't want to show the tour later on. This can happen in the following scenarios:
|
||||
// - the user deletes all the spaces but one (and that last space has a solution set)
|
||||
// - the user edits the default space and sets a solution
|
||||
// So we can immediately hide the tour in the global settings from now on.
|
||||
hideTourInGlobalSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onFinishTour = () => {
|
||||
hideTourInGlobalSettings();
|
||||
showTour$.next(false);
|
||||
};
|
||||
|
||||
return { showTour$: showTourObservable$, onFinishTour };
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { EuiButtonEmpty, EuiLink, EuiText, EuiTourStep } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
|
||||
import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type { SolutionView } from '../../../common';
|
||||
import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants';
|
||||
|
||||
const tourLearnMoreLink = 'https://ela.st/left-nav';
|
||||
|
||||
const LearnMoreLink = () => (
|
||||
<EuiLink href={tourLearnMoreLink} target="_blank" external>
|
||||
{i18n.translate('xpack.spaces.navControl.tour.learnMore', {
|
||||
defaultMessage: 'Learn more',
|
||||
})}
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
const solutionMap: Record<OnBoardingDefaultSolution, string> = {
|
||||
es: i18n.translate('xpack.spaces.navControl.tour.esSolution', {
|
||||
defaultMessage: 'Search',
|
||||
}),
|
||||
security: i18n.translate('xpack.spaces.navControl.tour.securitySolution', {
|
||||
defaultMessage: 'Security',
|
||||
}),
|
||||
oblt: i18n.translate('xpack.spaces.navControl.tour.obltSolution', {
|
||||
defaultMessage: 'Observability',
|
||||
}),
|
||||
};
|
||||
|
||||
interface Props extends PropsWithChildren<{}> {
|
||||
solution?: SolutionView;
|
||||
isTourOpen: boolean;
|
||||
onFinishTour: () => void;
|
||||
}
|
||||
|
||||
export const SolutionViewTour: FC<Props> = ({ children, solution, isTourOpen, onFinishTour }) => {
|
||||
const solutionLabel = solution && solution !== SOLUTION_VIEW_CLASSIC ? solutionMap[solution] : '';
|
||||
if (!solutionLabel) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiTourStep
|
||||
content={
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.spaces.navControl.tour.content"
|
||||
defaultMessage="It provides all the analytics and {solution} features you need. You can switch views or return to the classic navigation from your space settings, or create other spaces with different views. {learnMore}"
|
||||
values={{
|
||||
solution: solutionLabel,
|
||||
learnMore: <LearnMoreLink />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
}
|
||||
isStepOpen={isTourOpen}
|
||||
minWidth={300}
|
||||
maxWidth={360}
|
||||
onFinish={onFinishTour}
|
||||
step={1}
|
||||
stepsTotal={1}
|
||||
title={i18n.translate('xpack.spaces.navControl.tour.title', {
|
||||
defaultMessage: 'You chose the {solution} solution view',
|
||||
values: { solution: solutionLabel },
|
||||
})}
|
||||
anchorPosition="downCenter"
|
||||
footerAction={
|
||||
<EuiButtonEmpty size="s" color="text" onClick={onFinishTour} data-test-subj="closeTourBtn">
|
||||
{i18n.translate('xpack.spaces.navControl.tour.closeBtn', {
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
panelProps={{
|
||||
'data-test-subj': 'spaceSolutionTour',
|
||||
}}
|
||||
>
|
||||
<>{children}</>
|
||||
</EuiTourStep>
|
||||
);
|
||||
};
|
|
@ -35,6 +35,7 @@ import { SpacesClientService } from './spaces_client';
|
|||
import type { SpacesServiceSetup, SpacesServiceStart } from './spaces_service';
|
||||
import { SpacesService } from './spaces_service';
|
||||
import type { SpacesRequestHandlerContext } from './types';
|
||||
import { getUiSettings } from './ui_settings';
|
||||
import { registerSpacesUsageCollector } from './usage_collection';
|
||||
import { UsageStatsService } from './usage_stats';
|
||||
import { SpacesLicenseService } from '../common/licensing';
|
||||
|
@ -149,6 +150,7 @@ export class SpacesPlugin
|
|||
public setup(core: CoreSetup<PluginsStart>, plugins: PluginsSetup): SpacesPluginSetup {
|
||||
this.onCloud$.next(plugins.cloud !== undefined && plugins.cloud.isCloudEnabled);
|
||||
const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ });
|
||||
core.uiSettings.registerGlobal(getUiSettings());
|
||||
|
||||
const spacesServiceSetup = this.spacesService.setup({
|
||||
basePath: core.http.basePath,
|
||||
|
|
24
x-pack/plugins/spaces/server/ui_settings.ts
Normal file
24
x-pack/plugins/spaces/server/ui_settings.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { UiSettingsParams } from '@kbn/core/types';
|
||||
|
||||
import { SHOW_SPACE_SOLUTION_TOUR_SETTING } from '../common/constants';
|
||||
|
||||
/**
|
||||
* uiSettings definitions for Spaces
|
||||
*/
|
||||
export const getUiSettings = (): Record<string, UiSettingsParams> => {
|
||||
return {
|
||||
[SHOW_SPACE_SOLUTION_TOUR_SETTING]: {
|
||||
schema: schema.boolean(),
|
||||
readonly: true,
|
||||
readonlyMode: 'ui',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -75,6 +75,25 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) {
|
|||
};
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
updatedSpace: Partial<SpaceCreate>,
|
||||
{ overwrite = true }: { overwrite?: boolean } = {}
|
||||
) {
|
||||
log.debug(`updating space ${id}`);
|
||||
const { data, status, statusText } = await axios.put(
|
||||
`/api/spaces/space/${id}?overwrite=${overwrite}`,
|
||||
updatedSpace
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error(
|
||||
`Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}`
|
||||
);
|
||||
}
|
||||
log.debug(`updated space ${id}`);
|
||||
}
|
||||
|
||||
public async delete(spaceId: string) {
|
||||
log.debug(`deleting space id: ${spaceId}`);
|
||||
const { data, status, statusText } = await axios.delete(`/api/spaces/space/${spaceId}`);
|
||||
|
@ -87,6 +106,20 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) {
|
|||
log.debug(`deleted space id: ${spaceId}`);
|
||||
}
|
||||
|
||||
public async get(id: string) {
|
||||
log.debug(`retrieving space ${id}`);
|
||||
const { data, status, statusText } = await axios.get<Space>(`/api/spaces/space/${id}`);
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error(
|
||||
`Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}`
|
||||
);
|
||||
}
|
||||
log.debug(`retrieved space ${id}`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async getAll() {
|
||||
log.debug('retrieving all spaces');
|
||||
const { data, status, statusText } = await axios.get<Space[]>('/api/spaces/space');
|
||||
|
|
|
@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
|
|||
export default function spacesApp({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Spaces app (with solution view)', function spacesAppTestSuite() {
|
||||
loadTestFile(require.resolve('./create_edit_space'));
|
||||
loadTestFile(require.resolve('./solution_tour'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import type { SolutionView, Space } from '@kbn/spaces-plugin/common';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const spacesService = getService('spaces');
|
||||
const browser = getService('browser');
|
||||
const es = getService('es');
|
||||
const log = getService('log');
|
||||
|
||||
describe('space solution tour', () => {
|
||||
let version: string | undefined;
|
||||
|
||||
const removeGlobalSettings = async () => {
|
||||
version = version ?? (await kibanaServer.version.get());
|
||||
version = version.replace(/-SNAPSHOT$/, '');
|
||||
|
||||
log.debug(`Deleting [config-global:${version}] doc from the .kibana index`);
|
||||
|
||||
await es
|
||||
.delete(
|
||||
{ id: `config-global:${version}`, index: '.kibana', refresh: true },
|
||||
{ headers: { 'kbn-xsrf': 'spaces' } }
|
||||
)
|
||||
.catch((error) => {
|
||||
if (error.statusCode === 404) return; // ignore 404 errors
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
});
|
||||
|
||||
describe('solution tour', () => {
|
||||
let _defaultSpace: Space | undefined = {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
disabledFeatures: [],
|
||||
};
|
||||
|
||||
const updateSolutionDefaultSpace = async (solution: SolutionView) => {
|
||||
log.debug(`Updating default space solution: [${solution}].`);
|
||||
|
||||
await spacesService.update('default', {
|
||||
..._defaultSpace,
|
||||
solution,
|
||||
});
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
_defaultSpace = await spacesService.get('default');
|
||||
|
||||
await PageObjects.common.navigateToUrl('management', 'kibana/spaces', {
|
||||
shouldUseHashForSubUrl: false,
|
||||
});
|
||||
|
||||
await PageObjects.common.sleep(1000); // wait to save the setting
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await updateSolutionDefaultSpace('classic'); // revert to not impact future tests
|
||||
});
|
||||
|
||||
it('does not show the solution tour for the classic space', async () => {
|
||||
await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('does show the solution tour if the default space has a solution set', async () => {
|
||||
await updateSolutionDefaultSpace('es'); // set a solution
|
||||
await PageObjects.common.sleep(500);
|
||||
await removeGlobalSettings(); // Make sure we start from a clean state
|
||||
await browser.refresh();
|
||||
|
||||
await testSubjects.existOrFail('spaceSolutionTour', { timeout: 3000 });
|
||||
|
||||
await testSubjects.click('closeTourBtn'); // close the tour
|
||||
await PageObjects.common.sleep(1000); // wait to save the setting
|
||||
|
||||
await browser.refresh();
|
||||
await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); // The tour does not appear after refresh
|
||||
});
|
||||
|
||||
it('does not show the solution tour after updating the default space from classic to solution', async () => {
|
||||
await updateSolutionDefaultSpace('es'); // set a solution
|
||||
await PageObjects.common.sleep(500);
|
||||
await browser.refresh();
|
||||
|
||||
// The tour does not appear after refresh, even with the default space with a solution set
|
||||
await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('does not show the solution tour after deleting spaces and leave only the default', async () => {
|
||||
await updateSolutionDefaultSpace('es'); // set a solution
|
||||
|
||||
await spacesService.create({
|
||||
id: 'foo-space',
|
||||
name: 'Foo Space',
|
||||
disabledFeatures: [],
|
||||
color: '#AABBCC',
|
||||
});
|
||||
|
||||
const allSpaces = await spacesService.getAll();
|
||||
expect(allSpaces).to.have.length(2); // Make sure we have 2 spaces
|
||||
|
||||
await removeGlobalSettings(); // Make sure we start from a clean state
|
||||
await browser.refresh();
|
||||
|
||||
await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 });
|
||||
|
||||
await spacesService.delete('foo-space');
|
||||
await browser.refresh();
|
||||
|
||||
// The tour still does not appear after refresh, even with 1 space with a solution set
|
||||
await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue