diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index dd39fa0287f27..3444b3edd8078 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -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: { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts index 4d9d625970b7d..d1be94aad246a 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts @@ -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 = { + 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', + }); + } + }); }); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts index 1a8c05f13774f..6f77705069eaf 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts @@ -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(null); private activeNodes$ = new BehaviorSubject([]); + // Keep a reference to the nav node selected when the navigation panel is opened + private readonly panelSelectedNode$ = new BehaviorSubject( + 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.'); diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts index 1380214e4311e..ff39293738ff0 100644 --- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts +++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts @@ -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(), diff --git a/packages/core/chrome/core-chrome-browser/index.ts b/packages/core/chrome/core-chrome-browser/index.ts index 6be7f1077e6aa..4400c5e7d2b3f 100644 --- a/packages/core/chrome/core-chrome-browser/index.ts +++ b/packages/core/chrome/core-chrome-browser/index.ts @@ -51,6 +51,7 @@ export type { GroupDefinition, ItemDefinition, PresetDefinition, + PanelSelectedNode, RecentlyAccessedDefinition, NavigationGroupPreset, RootNavigationItemDefinition, diff --git a/packages/core/chrome/core-chrome-browser/src/contracts.ts b/packages/core/chrome/core-chrome-browser/src/contracts.ts index 1e9ea66bc0920..9fe54c971bbc7 100644 --- a/packages/core/chrome/core-chrome-browser/src/contracts.ts +++ b/packages/core/chrome/core-chrome-browser/src/contracts.ts @@ -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; + + /** + * 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; }; /** diff --git a/packages/core/chrome/core-chrome-browser/src/index.ts b/packages/core/chrome/core-chrome-browser/src/index.ts index ad041e8a3e297..7247bfe69710a 100644 --- a/packages/core/chrome/core-chrome-browser/src/index.ts +++ b/packages/core/chrome/core-chrome-browser/src/index.ts @@ -31,6 +31,7 @@ export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types'; export type { ChromeProjectNavigationNode, + PanelSelectedNode, AppDeepLinkId, AppId, CloudLinkId, diff --git a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts index 91f84d6c32145..417deea8e003e 100644 --- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts +++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts @@ -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[][]; diff --git a/packages/shared-ux/chrome/navigation/src/services.tsx b/packages/shared-ux/chrome/navigation/src/services.tsx index 1b0102533e53e..26a8b61478ecb 100644 --- a/packages/shared-ux/chrome/navigation/src/services.tsx +++ b/packages/shared-ux/chrome/navigation/src/services.tsx @@ -37,6 +37,7 @@ export const NavigationKibanaProvider: FC ({ @@ -47,6 +48,8 @@ export const NavigationKibanaProvider: FC; isSideNavCollapsed: boolean; eventTracker: EventTracker; + selectedPanelNode?: PanelSelectedNode | null; + setSelectedPanelNode?: (node: PanelSelectedNode | null) => void; } /** @@ -55,6 +58,8 @@ export interface NavigationKibanaDependencies { }; sideNav: { getIsCollapsed$: () => Observable; + getPanelSelectedNode$: () => Observable; + setPanelSelectedNode(node: string | PanelSelectedNode | null): void; }; }; http: { diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx index a5fee7c226fbd..917862b08b2b4 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/context.tsx @@ -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(null); interface Props { contentProvider?: ContentProvider; activeNodes: ChromeProjectNavigationNode[][]; + selectedNode?: PanelSelectedNode | null; + setSelectedNode?: (node: PanelSelectedNode | null) => void; } export const PanelProvider: FC> = ({ children, contentProvider, activeNodes, + selectedNode: selectedNodeProp = null, + setSelectedNode, }) => { const [isOpen, setIsOpen] = useState(false); - const [selectedNode, setActiveNode] = useState(null); + const [selectedNode, setActiveNode] = useState(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) { diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/default_content.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/panel/default_content.tsx index 7f18dd8358a8d..9892cc3a6f44a 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/default_content.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/default_content.tsx @@ -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) { return children !== undefined; @@ -33,7 +32,7 @@ function isItemNode({ children }: Pick) * @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 = ({ selectedNode }) => { diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx index 8e63e1179c955..5077cefc44625 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/navigation_panel.tsx @@ -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; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/types.ts b/packages/shared-ux/chrome/navigation/src/ui/components/panel/types.ts index 33af8c9e91ceb..8263e1f81c553 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/types.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/types.ts @@ -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; -}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx index b762ede8959da..3dacd01f8465f 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx @@ -47,7 +47,7 @@ export interface Props { } const NavigationComp: FC = ({ 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 = ({ navigationTree$, dataTestSubj, panelContent ); return ( - + {/* Main navigation content */} diff --git a/src/plugins/management/public/components/landing/classic_empty_prompt.tsx b/src/plugins/management/public/components/landing/classic_empty_prompt.tsx new file mode 100644 index 0000000000000..83bde6178644d --- /dev/null +++ b/src/plugins/management/public/components/landing/classic_empty_prompt.tsx @@ -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 = ({ kibanaVersion }) => { + return ( + + + + } + body={ + <> +

+ +

+ +

+ +

+ + } + /> + ); +}; diff --git a/src/plugins/management/public/components/landing/landing.test.tsx b/src/plugins/management/public/components/landing/landing.test.tsx index 99b499f0b8537..2f9371bbde031 100644 --- a/src/plugins/management/public/components/landing/landing.test.tsx +++ b/src/plugins/management/public/components/landing/landing.test.tsx @@ -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); + }); + }); }); diff --git a/src/plugins/management/public/components/landing/landing.tsx b/src/plugins/management/public/components/landing/landing.tsx index d17c03a5f0fa8..ffa5c7d0fd7e2 100644 --- a/src/plugins/management/public/components/landing/landing.tsx +++ b/src/plugins/management/public/components/landing/landing.tsx @@ -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 ( - - - - } - body={ - <> -

- -

- -

- -

- - } - /> - ); + if (!chromeStyle) return null; + + if (chromeStyle === 'project') { + return ; + } + + return ; }; diff --git a/src/plugins/management/public/components/landing/solution_empty_prompt.tsx b/src/plugins/management/public/components/landing/solution_empty_prompt.tsx new file mode 100644 index 0000000000000..63e3e91fd6ff3 --- /dev/null +++ b/src/plugins/management/public/components/landing/solution_empty_prompt.tsx @@ -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 }) => ( + + {i18n.translate('management.landing.subhead.indicesLink', { + defaultMessage: 'indices', + })} + +); + +const DataViewsLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => ( + + {i18n.translate('management.landing.subhead.dataViewsLink', { + defaultMessage: 'data views', + })} + +); + +const IngestPipelinesLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => ( + + {i18n.translate('management.landing.subhead.ingestPipelinesLink', { + defaultMessage: 'ingest pipelines', + })} + +); + +const UsersLink: FC<{ coreStart: CoreStart }> = ({ coreStart }) => ( + + {i18n.translate('management.landing.subhead.usersLink', { + defaultMessage: 'users', + })} + +); + +export const SolutionEmptyPrompt: FC = ({ kibanaVersion, coreStart }) => { + return ( + + + + } + body={ + <> +

+ , + dataViewsLink: , + ingestPipelinesLink: , + usersLink: , + }} + /> +

+ +

+ { + coreStart.chrome.sideNav.setPanelSelectedNode('stack_management'); + }} + data-test-subj="viewAllStackMngtPagesButton" + > + + +

+ + } + /> + ); +}; diff --git a/src/plugins/management/public/components/management_app/management_app.tsx b/src/plugins/management/public/components/management_app/management_app.tsx index e4ffd237b94c9..849d8f9eb0341 100644 --- a/src/plugins/management/public/components/management_app/management_app.tsx +++ b/src/plugins/management/public/components/management_app/management_app.tsx @@ -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; cardsNavigationConfig$: BehaviorSubject; + chromeStyle$: Observable; } export const ManagementApp = ({ dependencies, history, appBasePath }: ManagementAppProps) => { - const { coreStart, setBreadcrumbs, isSidebarEnabled$, cardsNavigationConfig$ } = dependencies; + const { coreStart, setBreadcrumbs, isSidebarEnabled$, cardsNavigationConfig$, chromeStyle$ } = + dependencies; const [selectedId, setSelectedId] = useState(''); const [sections, setSections] = useState(); 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 ( diff --git a/src/plugins/management/public/plugin.tsx b/src/plugins/management/public/plugin.tsx index 8804e8010c2e9..8f8f0f6c0339b 100644 --- a/src/plugins/management/public/plugin.tsx +++ b/src/plugins/management/public/plugin.tsx @@ -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$, }); }, }); diff --git a/src/plugins/management/public/types.ts b/src/plugins/management/public/types.ts index f5bb426ea689f..2e6f900de6298 100644 --- a/src/plugins/management/public/types.ts +++ b/src/plugins/management/public/types.ts @@ -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 { diff --git a/src/plugins/management/tsconfig.json b/src/plugins/management/tsconfig.json index 91e0e940c8fc6..01b1f62b3ba15 100644 --- a/src/plugins/management/tsconfig.json +++ b/src/plugins/management/tsconfig.json @@ -28,6 +28,7 @@ "@kbn/shared-ux-error-boundary", "@kbn/deeplinks-management", "@kbn/react-kibana-context-render", + "@kbn/core-chrome-browser", ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/enterprise_search/public/navigation_tree.ts b/x-pack/plugins/enterprise_search/public/navigation_tree.ts index 9264bf5de9750..74db04a3141da 100644 --- a/x-pack/plugins/enterprise_search/public/navigation_tree.ts +++ b/x-pack/plugins/enterprise_search/public/navigation_tree.ts @@ -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, diff --git a/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts b/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts index 6d63c9c89eaf1..309d2f2264300 100644 --- a/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts +++ b/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts @@ -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', diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index a9861850cf13e..6e4c30c3fdec5 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -236,6 +236,7 @@ export class UsersGridPage extends Component { defaultMessage="Users" /> } + data-test-subj="securityUsersPageHeader" rightSideItems={ this.props.readOnly ? undefined diff --git a/x-pack/plugins/security_solution_ess/public/navigation/index.ts b/x-pack/plugins/security_solution_ess/public/navigation/index.ts index b32adde0e211c..9b28bef8c403d 100644 --- a/x-pack/plugins/security_solution_ess/public/navigation/index.ts +++ b/x-pack/plugins/security_solution_ess/public/navigation/index.ts @@ -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); }; diff --git a/x-pack/plugins/security_solution_ess/public/navigation/management_cards.ts b/x-pack/plugins/security_solution_ess/public/navigation/management_cards.ts deleted file mode 100644 index c2b302956384a..0000000000000 --- a/x-pack/plugins/security_solution_ess/public/navigation/management_cards.ts +++ /dev/null @@ -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([ - [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>( - (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, - }); - }); -}; diff --git a/x-pack/plugins/security_solution_ess/tsconfig.json b/x-pack/plugins/security_solution_ess/tsconfig.json index f38811b701391..6409a15bd7d27 100644 --- a/x-pack/plugins/security_solution_ess/tsconfig.json +++ b/x-pack/plugins/security_solution_ess/tsconfig.json @@ -24,7 +24,6 @@ "@kbn/security-solution-upselling", "@kbn/i18n", "@kbn/navigation-plugin", - "@kbn/management-cards-navigation", "@kbn/management-plugin", "@kbn/core-chrome-browser", ] diff --git a/x-pack/test/functional/apps/management/config.ts b/x-pack/test/functional/apps/management/config.ts index d0d07ff200281..e4a06d30f260d 100644 --- a/x-pack/test/functional/apps/management/config.ts +++ b/x-pack/test/functional/apps/management/config.ts @@ -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', + ], + }, }; } diff --git a/x-pack/test/functional/apps/management/index.ts b/x-pack/test/functional/apps/management/index.ts index 72da3e0fd739a..7f84137b81be8 100644 --- a/x-pack/test/functional/apps/management/index.ts +++ b/x-pack/test/functional/apps/management/index.ts @@ -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')); }); } diff --git a/x-pack/test/functional/apps/management/landing_page.ts b/x-pack/test/functional/apps/management/landing_page.ts new file mode 100644 index 0000000000000..54a360c2e674d --- /dev/null +++ b/x-pack/test/functional/apps/management/landing_page.ts @@ -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 = () => 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 }); + }); + }); + }); +}