[8.x] [Stateful sidenav] Welcome tour (#194926) (#196298)

# 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:
Sébastien Loix 2024-10-15 15:35:19 +01:00 committed by GitHub
parent ebfc4d93ea
commit db2adf7588
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 509 additions and 25 deletions

View file

@ -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';

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>,

View file

@ -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();
});
});

View file

@ -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();
}

View file

@ -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';

View file

@ -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 };
}

View file

@ -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>
);
};

View file

@ -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,

View 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',
},
};
};

View file

@ -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');

View file

@ -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'));
});
}

View file

@ -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 });
});
});
});
}