[8.x] [Stateful sidenav] Update stack management landing page (#191735) (#193867)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Stateful sidenav] Update stack management landing page
(#191735)](https://github.com/elastic/kibana/pull/191735)

<!--- Backport version: 9.4.3 -->

### 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-09-24T13:01:02Z","message":"[Stateful
sidenav] Update stack management landing page
(#191735)","sha":"92f13200194e2faf35aa95a21e95d39323b2e824","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:SharedUX","backport:prev-minor","ci:project-deploy-observability","Team:obs-ux-management"],"title":"[Stateful
sidenav] Update stack management landing
page","number":191735,"url":"https://github.com/elastic/kibana/pull/191735","mergeCommit":{"message":"[Stateful
sidenav] Update stack management landing page
(#191735)","sha":"92f13200194e2faf35aa95a21e95d39323b2e824"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/191735","number":191735,"mergeCommit":{"message":"[Stateful
sidenav] Update stack management landing page
(#191735)","sha":"92f13200194e2faf35aa95a21e95d39323b2e824"}}]}]
BACKPORT-->

Co-authored-by: Sébastien Loix <sebastien.loix@elastic.co>
This commit is contained in:
Kibana Machine 2024-09-25 00:32:05 +10:00 committed by GitHub
parent 4686f3544c
commit 1e7765687c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 529 additions and 125 deletions

View file

@ -568,6 +568,8 @@ export class ChromeService {
sideNav: {
getIsCollapsed$: () => this.isSideNavCollapsed$.asObservable(),
setIsCollapsed: setIsSideNavCollapsed,
getPanelSelectedNode$: projectNavigation.getPanelSelectedNode$.bind(projectNavigation),
setPanelSelectedNode: projectNavigation.setPanelSelectedNode.bind(projectNavigation),
},
getActiveSolutionNavId$: () => projectNavigation.getActiveSolutionNavId$(),
project: {

View file

@ -1003,4 +1003,69 @@ describe('solution navigations', () => {
expect(activeSolution).toEqual(solution1);
}
});
it('should set and return the nav panel selected node', async () => {
const { projectNavigation } = setup({ navLinkIds: ['link1', 'link2', 'link3'] });
{
const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());
expect(selectedNode).toBeNull();
}
{
const node: ChromeProjectNavigationNode = {
id: 'node1',
title: 'Node 1',
path: 'node1',
};
projectNavigation.setPanelSelectedNode(node);
const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());
expect(selectedNode).toBe(node);
}
{
const fooSolution: SolutionNavigationDefinition<any> = {
id: 'fooSolution',
title: 'Foo solution',
icon: 'logoSolution',
homePage: 'discover',
navigationTree$: of({
body: [
{
type: 'navGroup',
id: 'group1',
children: [
{ link: 'link1' },
{
id: 'group2',
children: [
{
link: 'link2', // We'll target this node using its id
},
],
},
{ link: 'link3' },
],
},
],
}),
};
projectNavigation.changeActiveSolutionNavigation('foo');
projectNavigation.updateSolutionNavigations({ foo: fooSolution });
projectNavigation.setPanelSelectedNode('link2'); // Set the selected node using its id
const selectedNode = await firstValueFrom(projectNavigation.getPanelSelectedNode$());
expect(selectedNode).toMatchObject({
id: 'link2',
href: '/app/link2',
path: 'group1.group2.link2',
title: 'LINK2',
});
}
});
});

View file

@ -74,6 +74,10 @@ export class ProjectNavigationService {
// The navigation tree for the Side nav UI that still contains layout information (body, footer, etc.)
private navigationTreeUi$ = new BehaviorSubject<NavigationTreeDefinitionUI | null>(null);
private activeNodes$ = new BehaviorSubject<ChromeProjectNavigationNode[][]>([]);
// Keep a reference to the nav node selected when the navigation panel is opened
private readonly panelSelectedNode$ = new BehaviorSubject<ChromeProjectNavigationNode | null>(
null
);
private projectBreadcrumbs$ = new BehaviorSubject<{
breadcrumbs: ChromeProjectBreadcrumb[];
@ -187,6 +191,8 @@ export class ProjectNavigationService {
getActiveSolutionNavDefinition$: this.getActiveSolutionNavDefinition$.bind(this),
/** In stateful Kibana, get the id of the active solution navigation */
getActiveSolutionNavId$: () => this.activeSolutionNavDefinitionId$.asObservable(),
getPanelSelectedNode$: () => this.panelSelectedNode$.asObservable(),
setPanelSelectedNode: this.setPanelSelectedNode.bind(this),
};
}
@ -415,6 +421,34 @@ export class ProjectNavigationService {
}
}
private setPanelSelectedNode = (_node: string | ChromeProjectNavigationNode | null) => {
const node = typeof _node === 'string' ? this.findNodeById(_node) : _node;
this.panelSelectedNode$.next(node);
};
private findNodeById(id: string): ChromeProjectNavigationNode | null {
const allNodes = this.navigationTree$.getValue();
if (!allNodes) return null;
const find = (nodes: ChromeProjectNavigationNode[]): ChromeProjectNavigationNode | null => {
// Recursively search for the node with the given id
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = find(node.children);
if (found) {
return found;
}
}
}
return null;
};
return find(allNodes);
}
private get http() {
if (!this._http) {
throw new Error('Http service not provided.');

View file

@ -54,6 +54,8 @@ const createStartContractMock = () => {
sideNav: {
getIsCollapsed$: jest.fn(),
setIsCollapsed: jest.fn(),
getPanelSelectedNode$: jest.fn(),
setPanelSelectedNode: jest.fn(),
},
getBreadcrumbsAppendExtension$: jest.fn(),
setBreadcrumbsAppendExtension: jest.fn(),

View file

@ -51,6 +51,7 @@ export type {
GroupDefinition,
ItemDefinition,
PresetDefinition,
PanelSelectedNode,
RecentlyAccessedDefinition,
NavigationGroupPreset,
RootNavigationItemDefinition,

View file

@ -16,6 +16,7 @@ import type { ChromeHelpExtension } from './help_extension';
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
import type { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
import type { PanelSelectedNode } from './project_navigation';
/**
* ChromeStart allows plugins to customize the global chrome header UI and
@ -184,6 +185,20 @@ export interface ChromeStart {
* @param isCollapsed The collapsed state of the side nav.
*/
setIsCollapsed(isCollapsed: boolean): void;
/**
* Get an observable of the selected nav node that opens the side nav panel.
*/
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;
/**
* Set the selected nav node that opens the side nav panel.
*
* @param node The selected nav node that opens the side nav panel. If a string is provided,
* it will be used as the **id** of the selected nav node. If `null` is provided, the side nav panel
* will be closed.
*/
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
};
/**

View file

@ -31,6 +31,7 @@ export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types';
export type {
ChromeProjectNavigationNode,
PanelSelectedNode,
AppDeepLinkId,
AppId,
CloudLinkId,

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ComponentType, MouseEventHandler } from 'react';
import type { ComponentType, MouseEventHandler, ReactNode } from 'react';
import type { Location } from 'history';
import type { EuiSideNavItemType, EuiThemeSizes, IconType } from '@elastic/eui';
import type { Observable } from 'rxjs';
@ -247,6 +247,13 @@ export interface ChromeProjectNavigationNode extends NodeDefinitionBase {
isElasticInternalLink?: boolean;
}
export type PanelSelectedNode = Pick<
ChromeProjectNavigationNode,
'id' | 'children' | 'path' | 'sideNavStatus' | 'deepLink'
> & {
title: string | ReactNode;
};
/** @public */
export interface SideNavCompProps {
activeNodes: ChromeProjectNavigationNode[][];

View file

@ -37,6 +37,7 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
const { basePath } = http;
const { navigateToUrl } = core.application;
const isSideNavCollapsed = useObservable(chrome.sideNav.getIsCollapsed$(), true);
const selectedPanelNode = useObservable(chrome.sideNav.getPanelSelectedNode$(), null);
const value: NavigationServices = useMemo(
() => ({
@ -47,6 +48,8 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
activeNodes$,
isSideNavCollapsed,
eventTracker: new EventTracker({ reportEvent: analytics.reportEvent }),
selectedPanelNode,
setSelectedPanelNode: chrome.sideNav.setPanelSelectedNode,
}),
[
activeNodes$,
@ -55,6 +58,8 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
chrome.recentlyAccessed,
isSideNavCollapsed,
navigateToUrl,
selectedPanelNode,
chrome.sideNav.setPanelSelectedNode,
]
);

View file

@ -15,6 +15,7 @@ import type {
ChromeNavLink,
ChromeProjectNavigationNode,
ChromeRecentlyAccessedHistoryItem,
PanelSelectedNode,
} from '@kbn/core-chrome-browser';
import { EventTracker } from './analytics';
@ -38,6 +39,8 @@ export interface NavigationServices {
activeNodes$: Observable<ChromeProjectNavigationNode[][]>;
isSideNavCollapsed: boolean;
eventTracker: EventTracker;
selectedPanelNode?: PanelSelectedNode | null;
setSelectedPanelNode?: (node: PanelSelectedNode | null) => void;
}
/**
@ -55,6 +58,8 @@ export interface NavigationKibanaDependencies {
};
sideNav: {
getIsCollapsed$: () => Observable<boolean>;
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
};
};
http: {

View file

@ -15,19 +15,20 @@ import React, {
useMemo,
useState,
ReactNode,
useEffect,
} from 'react';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-chrome-browser';
import { DefaultContent } from './default_content';
import { ContentProvider, PanelNavNode } from './types';
import { ContentProvider } from './types';
export interface PanelContext {
isOpen: boolean;
toggle: () => void;
open: (navNode: PanelNavNode) => void;
open: (navNode: PanelSelectedNode) => void;
close: () => void;
/** The selected node is the node in the main panel that opens the Panel */
selectedNode: PanelNavNode | null;
selectedNode: PanelSelectedNode | null;
/** Handler to retrieve the component to render in the panel */
getContent: () => React.ReactNode;
}
@ -37,29 +38,50 @@ const Context = React.createContext<PanelContext | null>(null);
interface Props {
contentProvider?: ContentProvider;
activeNodes: ChromeProjectNavigationNode[][];
selectedNode?: PanelSelectedNode | null;
setSelectedNode?: (node: PanelSelectedNode | null) => void;
}
export const PanelProvider: FC<PropsWithChildren<Props>> = ({
children,
contentProvider,
activeNodes,
selectedNode: selectedNodeProp = null,
setSelectedNode,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedNode, setActiveNode] = useState<PanelNavNode | null>(null);
const [selectedNode, setActiveNode] = useState<PanelSelectedNode | null>(selectedNodeProp);
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const open = useCallback((navNode: PanelNavNode) => {
setActiveNode(navNode);
setIsOpen(true);
}, []);
const open = useCallback(
(navNode: PanelSelectedNode) => {
setActiveNode(navNode);
setIsOpen(true);
setSelectedNode?.(navNode);
},
[setSelectedNode]
);
const close = useCallback(() => {
setActiveNode(null);
setIsOpen(false);
}, []);
setSelectedNode?.(null);
}, [setSelectedNode]);
useEffect(() => {
if (selectedNodeProp === undefined) return;
setActiveNode(selectedNodeProp);
if (selectedNodeProp) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [selectedNodeProp]);
const getContent = useCallback(() => {
if (!selectedNode) {

View file

@ -8,12 +8,11 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-chrome-browser';
import React, { Fragment, type FC } from 'react';
import { PanelGroup } from './panel_group';
import { PanelNavItem } from './panel_nav_item';
import type { PanelNavNode } from './types';
function isGroupNode({ children }: Pick<ChromeProjectNavigationNode, 'children'>) {
return children !== undefined;
@ -33,7 +32,7 @@ function isItemNode({ children }: Pick<ChromeProjectNavigationNode, 'children'>)
* @param node The current active node
* @returns The children serialized
*/
function serializeChildren(node: PanelNavNode): ChromeProjectNavigationNode[] | undefined {
function serializeChildren(node: PanelSelectedNode): ChromeProjectNavigationNode[] | undefined {
if (!node.children) return undefined;
const allChildrenAreItems = node.children.every((_node) => {
@ -69,7 +68,7 @@ function serializeChildren(node: PanelNavNode): ChromeProjectNavigationNode[] |
interface Props {
/** The selected node is the node in the main panel that opens the Panel */
selectedNode: PanelNavNode;
selectedNode: PanelSelectedNode;
}
export const DefaultContent: FC<Props> = ({ selectedNode }) => {

View file

@ -18,11 +18,11 @@ import {
import React, { useCallback, type FC } from 'react';
import classNames from 'classnames';
import type { PanelSelectedNode } from '@kbn/core-chrome-browser';
import { usePanel } from './context';
import { getNavPanelStyles, getPanelWrapperStyles } from './styles';
import { PanelNavNode } from './types';
const getTestSubj = (selectedNode: PanelNavNode | null): string | undefined => {
const getTestSubj = (selectedNode: PanelSelectedNode | null): string | undefined => {
if (!selectedNode) return;
const deeplinkId = selectedNode.deepLink?.id;

View file

@ -8,13 +8,13 @@
*/
import type { ReactNode, ComponentType } from 'react';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { ChromeProjectNavigationNode, PanelSelectedNode } from '@kbn/core-chrome-browser';
export interface PanelComponentProps {
/** Handler to close the panel */
closePanel: () => void;
/** The node in the main panel that opens the secondary panel */
selectedNode: PanelNavNode;
selectedNode: PanelSelectedNode;
/** Jagged array of active nodes that match the current URL location */
activeNodes: ChromeProjectNavigationNode[][];
}
@ -25,10 +25,3 @@ export interface PanelContent {
}
export type ContentProvider = (nodeId: string) => PanelContent | void;
export type PanelNavNode = Pick<
ChromeProjectNavigationNode,
'id' | 'children' | 'path' | 'sideNavStatus' | 'deepLink'
> & {
title: string | ReactNode;
};

View file

@ -47,7 +47,7 @@ export interface Props {
}
const NavigationComp: FC<Props> = ({ navigationTree$, dataTestSubj, panelContentProvider }) => {
const { activeNodes$ } = useNavigationService();
const { activeNodes$, selectedPanelNode, setSelectedPanelNode } = useNavigationService();
const activeNodes = useObservable(activeNodes$, []);
const navigationTree = useObservable(navigationTree$, { body: [] });
@ -79,7 +79,12 @@ const NavigationComp: FC<Props> = ({ navigationTree$, dataTestSubj, panelContent
);
return (
<PanelProvider activeNodes={activeNodes} contentProvider={panelContentProvider}>
<PanelProvider
activeNodes={activeNodes}
contentProvider={panelContentProvider}
selectedNode={selectedPanelNode}
setSelectedNode={setSelectedPanelNode}
>
<NavigationContext.Provider value={contextValue}>
{/* Main navigation content */}
<EuiCollapsibleNavBeta.Body data-test-subj={dataTestSubj}>

View file

@ -0,0 +1,51 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { type FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiHorizontalRule } from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
interface Props {
kibanaVersion: string;
}
export const ClassicEmptyPrompt: FC<Props> = ({ kibanaVersion }) => {
return (
<KibanaPageTemplate.EmptyPrompt
data-test-subj="managementHome"
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="management.landing.header"
defaultMessage="Welcome to Stack Management {version}"
values={{ version: kibanaVersion }}
/>
</h1>
}
body={
<>
<p>
<FormattedMessage
id="management.landing.subhead"
defaultMessage="Manage your indices, data views, saved objects, Kibana settings, and more."
/>
</p>
<EuiHorizontalRule />
<p>
<FormattedMessage
id="management.landing.text"
defaultMessage="A complete list of apps is in the menu on the left."
/>
</p>
</>
}
/>
);
};

View file

@ -9,6 +9,7 @@
import React from 'react';
import { merge } from 'lodash';
import { coreMock } from '@kbn/core/public/mocks';
import { registerTestBed, AsyncTestBedConfig, TestBed } from '@kbn/test-jest-helpers';
import { AppContextProvider } from '../management_app/management_context';
@ -45,6 +46,7 @@ export const WithAppDependencies =
kibanaVersion: '8.10.0',
cardsNavigationConfig: { enabled: true },
sections: sectionsMock,
chromeStyle: 'classic',
};
return (
@ -88,4 +90,32 @@ describe('Landing Page', () => {
expect(exists('managementHome')).toBe(true);
});
});
describe('Empty prompt', () => {
test('Renders the default empty prompt when chromeStyle is "classic"', async () => {
testBed = await setupLandingPage({
chromeStyle: 'classic',
cardsNavigationConfig: { enabled: false },
});
const { exists } = testBed;
expect(exists('managementHome')).toBe(true);
});
test('Renders the solution empty prompt when chromeStyle is "project"', async () => {
const coreStart = coreMock.createStart();
testBed = await setupLandingPage({
chromeStyle: 'project',
cardsNavigationConfig: { enabled: false },
coreStart,
});
const { exists } = testBed;
expect(exists('managementHome')).toBe(false);
expect(exists('managementHomeSolution')).toBe(true);
});
});
});

View file

@ -8,13 +8,13 @@
*/
import React, { useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiHorizontalRule } from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { EuiPageBody } from '@elastic/eui';
import { CardsNavigation } from '@kbn/management-cards-navigation';
import { useAppContext } from '../management_app/management_context';
import { ClassicEmptyPrompt } from './classic_empty_prompt';
import { SolutionEmptyPrompt } from './solution_empty_prompt';
interface ManagementLandingPageProps {
onAppMounted: (id: string) => void;
@ -25,7 +25,8 @@ export const ManagementLandingPage = ({
setBreadcrumbs,
onAppMounted,
}: ManagementLandingPageProps) => {
const { appBasePath, sections, kibanaVersion, cardsNavigationConfig } = useAppContext();
const { appBasePath, sections, kibanaVersion, cardsNavigationConfig, chromeStyle, coreStart } =
useAppContext();
setBreadcrumbs();
useEffect(() => {
@ -45,36 +46,11 @@ export const ManagementLandingPage = ({
);
}
return (
<KibanaPageTemplate.EmptyPrompt
data-test-subj="managementHome"
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="management.landing.header"
defaultMessage="Welcome to Stack Management {version}"
values={{ version: kibanaVersion }}
/>
</h1>
}
body={
<>
<p>
<FormattedMessage
id="management.landing.subhead"
defaultMessage="Manage your indices, data views, saved objects, Kibana settings, and more."
/>
</p>
<EuiHorizontalRule />
<p>
<FormattedMessage
id="management.landing.text"
defaultMessage="A complete list of apps is in the menu on the left."
/>
</p>
</>
}
/>
);
if (!chromeStyle) return null;
if (chromeStyle === 'project') {
return <SolutionEmptyPrompt kibanaVersion={kibanaVersion} coreStart={coreStart} />;
}
return <ClassicEmptyPrompt kibanaVersion={kibanaVersion} />;
};

View file

@ -0,0 +1,113 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { type FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiLink } from '@elastic/eui';
import { type CoreStart } from '@kbn/core/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
interface Props {
kibanaVersion: string;
coreStart: CoreStart;
}
const IndicesLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => (
<EuiLink
href={coreStart.application.getUrlForApp('management', { path: 'data/index_management' })}
data-test-subj="managementLinkToIndices"
>
{i18n.translate('management.landing.subhead.indicesLink', {
defaultMessage: 'indices',
})}
</EuiLink>
);
const DataViewsLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => (
<EuiLink
href={coreStart.application.getUrlForApp('management', { path: 'kibana/dataViews' })}
data-test-subj="managementLinkToDataViews"
>
{i18n.translate('management.landing.subhead.dataViewsLink', {
defaultMessage: 'data views',
})}
</EuiLink>
);
const IngestPipelinesLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => (
<EuiLink
href={coreStart.application.getUrlForApp('management', { path: 'ingest/ingest_pipelines' })}
data-test-subj="managementLinkToIngestPipelines"
>
{i18n.translate('management.landing.subhead.ingestPipelinesLink', {
defaultMessage: 'ingest pipelines',
})}
</EuiLink>
);
const UsersLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => (
<EuiLink
href={coreStart.application.getUrlForApp('management', { path: 'security/users' })}
data-test-subj="managementLinkToUsers"
>
{i18n.translate('management.landing.subhead.usersLink', {
defaultMessage: 'users',
})}
</EuiLink>
);
export const SolutionEmptyPrompt: FC<Props> = ({ kibanaVersion, coreStart }) => {
return (
<KibanaPageTemplate.EmptyPrompt
data-test-subj="managementHomeSolution"
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="management.landing.solution.header"
defaultMessage="Stack Management {version}"
values={{ version: kibanaVersion }}
/>
</h1>
}
body={
<>
<p>
<FormattedMessage
id="management.landing.solution.subhead"
defaultMessage="Manage your {indicesLink}, {dataViewsLink}, {ingestPipelinesLink}, {usersLink}, and more."
values={{
indicesLink: <IndicesLink coreStart={coreStart} />,
dataViewsLink: <DataViewsLink coreStart={coreStart} />,
ingestPipelinesLink: <IngestPipelinesLink coreStart={coreStart} />,
usersLink: <UsersLink coreStart={coreStart} />,
}}
/>
</p>
<p>
<EuiButton
fill
iconType="spaces"
onClick={() => {
coreStart.chrome.sideNav.setPanelSelectedNode('stack_management');
}}
data-test-subj="viewAllStackMngtPagesButton"
>
<FormattedMessage
id="management.landing.solution.viewAllPagesButton"
defaultMessage="View all pages"
/>
</EuiButton>
</p>
</>
}
/>
);
};

View file

@ -10,7 +10,7 @@
import './management_app.scss';
import React, { useState, useEffect, useCallback } from 'react';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from '@kbn/core/public';
@ -21,6 +21,7 @@ import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaPageTemplate, KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import useObservable from 'react-use/lib/useObservable';
import type { ChromeStyle } from '@kbn/core-chrome-browser';
import { AppContextProvider } from './management_context';
import {
ManagementSection,
@ -29,7 +30,7 @@ import {
} from '../../utils';
import { ManagementRouter } from './management_router';
import { managementSidebarNav } from '../management_sidebar_nav/management_sidebar_nav';
import { SectionsServiceStart, NavigationCardsSubject } from '../../types';
import { SectionsServiceStart, NavigationCardsSubject, AppDependencies } from '../../types';
interface ManagementAppProps {
appBasePath: string;
@ -44,14 +45,17 @@ export interface ManagementAppDependencies {
setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void;
isSidebarEnabled$: BehaviorSubject<boolean>;
cardsNavigationConfig$: BehaviorSubject<NavigationCardsSubject>;
chromeStyle$: Observable<ChromeStyle>;
}
export const ManagementApp = ({ dependencies, history, appBasePath }: ManagementAppProps) => {
const { coreStart, setBreadcrumbs, isSidebarEnabled$, cardsNavigationConfig$ } = dependencies;
const { coreStart, setBreadcrumbs, isSidebarEnabled$, cardsNavigationConfig$, chromeStyle$ } =
dependencies;
const [selectedId, setSelectedId] = useState<string>('');
const [sections, setSections] = useState<ManagementSection[]>();
const isSidebarEnabled = useObservable(isSidebarEnabled$);
const cardsNavigationConfig = useObservable(cardsNavigationConfig$);
const chromeStyle = useObservable(chromeStyle$);
const onAppMounted = useCallback((id: string) => {
setSelectedId(id);
@ -102,11 +106,13 @@ export const ManagementApp = ({ dependencies, history, appBasePath }: Management
}
: undefined;
const contextDependencies = {
const contextDependencies: AppDependencies = {
appBasePath,
sections,
cardsNavigationConfig,
kibanaVersion: dependencies.kibanaVersion,
coreStart,
chromeStyle,
};
return (

View file

@ -119,6 +119,7 @@ export class ManagementPlugin
async mount(params: AppMountParameters) {
const { renderApp } = await import('./application');
const [coreStart, deps] = await core.getStartServices();
const chromeStyle$ = coreStart.chrome.getChromeStyle$();
return renderApp(params, {
sections: getSectionsServiceStartPrivate(),
@ -135,6 +136,7 @@ export class ManagementPlugin
},
isSidebarEnabled$: managementPlugin.isSidebarEnabled$,
cardsNavigationConfig$: managementPlugin.cardsNavigationConfig$,
chromeStyle$,
});
},
});

View file

@ -8,10 +8,17 @@
*/
import { Observable } from 'rxjs';
import { ScopedHistory, Capabilities, ThemeServiceStart } from '@kbn/core/public';
import {
ScopedHistory,
Capabilities,
ThemeServiceStart,
CoreStart,
ChromeBreadcrumb,
CoreTheme,
} from '@kbn/core/public';
import type { LocatorPublic } from '@kbn/share-plugin/common';
import { ChromeBreadcrumb, CoreTheme } from '@kbn/core/public';
import type { CardsNavigationComponentProps } from '@kbn/management-cards-navigation';
import type { ChromeStyle } from '@kbn/core-chrome-browser';
import { ManagementSection, RegisterManagementSectionArgs } from './utils';
import type { ManagementAppLocatorParams } from '../common/locator';
@ -98,6 +105,8 @@ export interface AppDependencies {
kibanaVersion: string;
sections: ManagementSection[];
cardsNavigationConfig?: NavigationCardsSubject;
chromeStyle?: ChromeStyle;
coreStart: CoreStart;
}
export interface ConfigSchema {

View file

@ -28,6 +28,7 @@
"@kbn/shared-ux-error-boundary",
"@kbn/deeplinks-management",
"@kbn/react-kibana-context-render",
"@kbn/core-chrome-browser",
],
"exclude": [
"target/**/*"

View file

@ -348,6 +348,7 @@ export const getNavigationTreeDefinition = ({
title: 'Stack',
},
],
id: 'stack_management', // This id can't be changed as we use it to open the panel programmatically
link: 'management',
renderAs: 'panelOpener',
spaceBefore: null,

View file

@ -272,6 +272,7 @@ export function createNavTree(pluginsStart: ObservabilityPublicPluginsStart) {
breadcrumbStatus: 'hidden',
children: [
{
id: 'stack_management', // This id can't be changed as we use it to open the panel programmatically
link: 'management',
title: i18n.translate('xpack.observability.obltNav.stackManagement', {
defaultMessage: 'Stack Management',

View file

@ -236,6 +236,7 @@ export class UsersGridPage extends Component<Props, State> {
defaultMessage="Users"
/>
}
data-test-subj="securityUsersPageHeader"
rightSideItems={
this.props.readOnly
? undefined

View file

@ -7,11 +7,9 @@
import type { Services } from '../common/services';
import { subscribeBreadcrumbs } from './breadcrumbs';
import { enableManagementCardsLanding } from './management_cards';
import { initSideNavigation } from './side_navigation';
export const startNavigation = (services: Services) => {
initSideNavigation(services);
subscribeBreadcrumbs(services);
enableManagementCardsLanding(services);
};

View file

@ -1,52 +0,0 @@
/*
* 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 type { CardNavExtensionDefinition } from '@kbn/management-cards-navigation';
import {
getNavigationPropsFromId,
SecurityPageName,
ExternalPageName,
} from '@kbn/security-solution-navigation';
import { combineLatestWith } from 'rxjs';
import type { Services } from '../common/services';
const SecurityManagementCards = new Map<string, CardNavExtensionDefinition['category']>([
[ExternalPageName.visualize, 'content'],
[ExternalPageName.maps, 'content'],
[SecurityPageName.entityAnalyticsManagement, 'alerts'],
[SecurityPageName.entityAnalyticsAssetClassification, 'alerts'],
]);
export const enableManagementCardsLanding = (services: Services) => {
const { securitySolution, management, application, navigation } = services;
securitySolution
.getNavLinks$()
.pipe(combineLatestWith(navigation.isSolutionNavEnabled$))
.subscribe(([navLinks, isSolutionNavEnabled]) => {
const cardNavDefinitions = navLinks.reduce<Record<string, CardNavExtensionDefinition>>(
(acc, navLink) => {
if (SecurityManagementCards.has(navLink.id)) {
const { appId, deepLinkId, path } = getNavigationPropsFromId(navLink.id);
acc[navLink.id] = {
category: SecurityManagementCards.get(navLink.id) ?? 'other',
title: navLink.title,
description: navLink.description ?? '',
icon: navLink.landingIcon ?? '',
href: application.getUrlForApp(appId, { deepLinkId, path }),
skipValidation: true,
};
}
return acc;
},
{}
);
management.setupCardsNavigation({
enabled: isSolutionNavEnabled,
extendCardNavDefinitions: cardNavDefinitions,
});
});
};

View file

@ -24,7 +24,6 @@
"@kbn/security-solution-upselling",
"@kbn/i18n",
"@kbn/navigation-plugin",
"@kbn/management-cards-navigation",
"@kbn/management-plugin",
"@kbn/core-chrome-browser",
]

View file

@ -13,5 +13,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
kbnTestServer: {
...functionalConfig.get('kbnTestServer'),
serverArgs: [
...functionalConfig.get('kbnTestServer.serverArgs'),
'--xpack.spaces.experimental.forceSolutionVisibility=true',
],
},
};
}

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('management', function () {
loadTestFile(require.resolve('./create_index_pattern_wizard'));
loadTestFile(require.resolve('./feature_controls'));
loadTestFile(require.resolve('./landing_page'));
});
}

View file

@ -0,0 +1,104 @@
/*
* 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 } from '@kbn/spaces-plugin/common';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const browser = getService('browser');
const testSubjects = getService('testSubjects');
const spaces = getService('spaces');
const PageObjects = getPageObjects(['settings', 'common', 'dashboard', 'timePicker', 'header']);
describe('landing page', function describeIndexTests() {
let cleanUp: () => Promise<unknown> = () => Promise.resolve();
let spaceCreated: { id: string } = { id: '' };
it('should render the "classic" prompt', async function () {
await PageObjects.common.navigateToApp('management');
await testSubjects.existOrFail('managementHome', { timeout: 3000 });
});
describe('solution empty prompt', () => {
const createSpaceWithSolutionAndNavigateToManagement = async (solution: SolutionView) => {
({ cleanUp, space: spaceCreated } = await spaces.create({ solution }));
await PageObjects.common.navigateToApp('management', { basePath: `/s/${spaceCreated.id}` });
return async () => {
await cleanUp();
cleanUp = () => Promise.resolve();
};
};
afterEach(async function afterEach() {
await cleanUp();
});
/** Test that the empty prompt has a button to open the stack managment panel */
const testStackManagmentPanel = async () => {
await testSubjects.missingOrFail('~sideNavPanel-id-stack_management', { timeout: 1000 });
await testSubjects.click('~viewAllStackMngtPagesButton'); // open the side nav
await testSubjects.existOrFail('~sideNavPanel-id-stack_management', { timeout: 3000 });
};
const testCorrectEmptyPrompt = async () => {
await testSubjects.missingOrFail('managementHome', { timeout: 3000 });
await testSubjects.existOrFail('managementHomeSolution', { timeout: 3000 });
};
it('should render the "solution" prompt when the space has a solution set', async function () {
{
const deleteSpace = await createSpaceWithSolutionAndNavigateToManagement('es');
await testCorrectEmptyPrompt();
await testStackManagmentPanel();
await deleteSpace();
}
{
const deleteSpace = await createSpaceWithSolutionAndNavigateToManagement('oblt');
await testCorrectEmptyPrompt();
await testStackManagmentPanel();
await deleteSpace();
}
{
const deleteSpace = await createSpaceWithSolutionAndNavigateToManagement('security');
await testCorrectEmptyPrompt();
await testStackManagmentPanel();
await deleteSpace();
}
});
it('should have links to pages in management', async function () {
await createSpaceWithSolutionAndNavigateToManagement('es');
await testSubjects.click('~managementLinkToIndices', 3000);
await testSubjects.existOrFail('~indexManagementHeaderContent', { timeout: 3000 });
await browser.goBack();
await testSubjects.existOrFail('managementHomeSolution', { timeout: 3000 });
await testSubjects.click('~managementLinkToDataViews', 3000);
await testSubjects.existOrFail('~indexPatternTable', { timeout: 3000 });
await browser.goBack();
await testSubjects.existOrFail('managementHomeSolution', { timeout: 3000 });
await testSubjects.click('~managementLinkToIngestPipelines', 3000);
const appTitle = await testSubjects.getVisibleText('appTitle');
expect(appTitle).to.be('Ingest Pipelines');
// Note: for some reason, browser.goBack() does not work from Ingest Pipelines
// so using navigateToApp instead;
await PageObjects.common.navigateToApp('management', { basePath: `/s/${spaceCreated.id}` });
await testSubjects.existOrFail('managementHomeSolution', { timeout: 3000 });
await testSubjects.click('~managementLinkToUsers', 3000);
await testSubjects.existOrFail('~securityUsersPageHeader', { timeout: 3000 });
});
});
});
}