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 d6a7a2e065945..191edc708b64a 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 @@ -89,6 +89,7 @@ const createStartContractMock = () => { startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([])); startContract.hasHeaderBanner$.mockReturnValue(new BehaviorSubject(false)); + startContract.getIsSideNavCollapsed$.mockReturnValue(new BehaviorSubject(false)); return startContract; }; diff --git a/x-pack/packages/security-solution/side_nav/panel.ts b/x-pack/packages/security-solution/side_nav/panel.ts index a0341cd000812..d52f519c79b35 100644 --- a/x-pack/packages/security-solution/side_nav/panel.ts +++ b/x-pack/packages/security-solution/side_nav/panel.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { SolutionSideNavPanel } from './src/solution_side_nav_panel'; +export { SolutionSideNavPanelContent } from './src/solution_side_nav_panel'; diff --git a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx index e04f042f02960..dfe2f643d4783 100644 --- a/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx +++ b/x-pack/packages/security-solution/side_nav/src/solution_side_nav_panel.tsx @@ -50,12 +50,14 @@ import { accordionButtonClassName, } from './solution_side_nav_panel.styles'; -export interface SolutionSideNavPanelProps { - onClose: () => void; - onOutsideClick: () => void; +export interface SolutionSideNavPanelContentProps { title: string; + onClose: () => void; items: SolutionSideNavItem[]; categories?: LinkCategories; +} +export interface SolutionSideNavPanelProps extends SolutionSideNavPanelContentProps { + onOutsideClick: () => void; bottomOffset?: string; topOffset?: string; } @@ -85,7 +87,6 @@ export const SolutionSideNavPanel: React.FC = React.m $topOffset, }); const panelClasses = classNames(panelClassName, 'eui-yScroll', solutionSideNavPanelStyles); - const titleClasses = classNames(SolutionSideNavTitleStyles(euiTheme)); // ESC key closes PanelNav const onKeyDown = useCallback( @@ -110,24 +111,12 @@ export const SolutionSideNavPanel: React.FC = React.m paddingSize="m" data-test-subj="solutionSideNavPanel" > - - - - {title} - - - - {categories ? ( - - ) : ( - - )} - - + @@ -137,6 +126,33 @@ export const SolutionSideNavPanel: React.FC = React.m } ); +export const SolutionSideNavPanelContent: React.FC = React.memo( + function SolutionSideNavPanelContent({ title, onClose, categories, items }) { + const { euiTheme } = useEuiTheme(); + const titleClasses = classNames(SolutionSideNavTitleStyles(euiTheme)); + return ( + + + + {title} + + + + {categories ? ( + + ) : ( + + )} + + + ); + } +); + interface SolutionSideNavPanelCategoriesProps { categories: LinkCategories; items: SolutionSideNavItem[]; diff --git a/x-pack/packages/security-solution/side_nav/src/telemetry/telemetry_context.tsx b/x-pack/packages/security-solution/side_nav/src/telemetry/telemetry_context.tsx index c7e97969ad31c..aec5460ddc67a 100644 --- a/x-pack/packages/security-solution/side_nav/src/telemetry/telemetry_context.tsx +++ b/x-pack/packages/security-solution/side_nav/src/telemetry/telemetry_context.tsx @@ -20,9 +20,5 @@ export const TelemetryContextProvider: FC = ({ children, }; export const useTelemetryContext = () => { - const context = useContext(TelemetryContext); - if (!context) { - throw new Error('No TelemetryContext found.'); - } - return context; + return useContext(TelemetryContext) ?? {}; }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts b/x-pack/plugins/security_solution_serverless/public/navigation/categories.ts similarity index 56% rename from x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts rename to x-pack/plugins/security_solution_serverless/public/navigation/categories.ts index e7c9057d950ff..fe5e0bf6c1d2a 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/categories.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/categories.ts @@ -5,14 +5,17 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { SecurityPageName, LinkCategoryType, + type LinkCategory, type SeparatorLinkCategory, } from '@kbn/security-solution-navigation'; -import { ExternalPageName } from '../links/constants'; +import { ExternalPageName } from './links/constants'; +import type { ProjectPageName } from './links/types'; -export const CATEGORIES: SeparatorLinkCategory[] = [ +export const CATEGORIES: Array> = [ { type: LinkCategoryType.separator, linkIds: [ExternalPageName.discover, SecurityPageName.dashboards], @@ -43,3 +46,24 @@ export const CATEGORIES: SeparatorLinkCategory[] = [ linkIds: [SecurityPageName.mlLanding], }, ]; + +export const FOOTER_CATEGORIES: Array> = [ + { + type: LinkCategoryType.separator, + linkIds: [SecurityPageName.landing, ExternalPageName.devTools], + }, + { + type: LinkCategoryType.accordion, + label: i18n.translate('xpack.securitySolutionServerless.nav.projectSettings.title', { + defaultMessage: 'Project settings', + }), + iconType: 'gear', + linkIds: [ + ExternalPageName.management, + ExternalPageName.integrationsSecurity, + ExternalPageName.cloudUsersAndRoles, + ExternalPageName.cloudPerformance, + ExternalPageName.cloudBilling, + ], + }, +]; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx deleted file mode 100644 index 74f3d0e3afd3d..0000000000000 --- a/x-pack/plugins/security_solution_serverless/public/navigation/default_navigation.tsx +++ /dev/null @@ -1,47 +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 React, { Suspense } from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; -import type { SideNavComponent } from '@kbn/core-chrome-browser'; -import type { Services } from '../common/services'; - -const SecurityDefaultNavigationLazy = React.lazy(() => - import('@kbn/shared-ux-chrome-navigation').then( - ({ DefaultNavigation, NavigationKibanaProvider }) => ({ - default: React.memo<{ - navigationTree: NavigationTreeDefinition; - services: Services; - }>(function SecurityDefaultNavigation({ navigationTree, services }) { - return ( - - - - ); - }), - }) - ) -); - -export const getDefaultNavigationComponent = ( - navigationTree: NavigationTreeDefinition, - services: Services -): SideNavComponent => - function SecuritySideNavComponent() { - return ( - }> - - - ); - }; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts index 1504f8dfea8c2..0dd14ebacd544 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/index.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/index.ts @@ -9,10 +9,11 @@ import { APP_PATH } from '@kbn/security-solution-plugin/common'; import type { CoreSetup } from '@kbn/core/public'; import type { SecuritySolutionServerlessPluginSetupDeps } from '../types'; import type { Services } from '../common/services'; +import { withServicesProvider } from '../common/services'; import { subscribeBreadcrumbs } from './breadcrumbs'; import { ProjectNavigationTree } from './navigation_tree'; import { getSecuritySideNavComponent } from './side_navigation'; -import { getDefaultNavigationComponent } from './default_navigation'; +import { SecuritySideNavComponent } from './project_navigation'; import { projectAppLinksSwitcher } from './links/app_links'; import { formatProjectDeepLinks } from './links/deep_links'; @@ -28,15 +29,14 @@ export const startNavigation = (services: Services) => { const { serverless, management } = services; serverless.setProjectHome(APP_PATH); + management.setupCardsNavigation({ enabled: true }); + const projectNavigationTree = new ProjectNavigationTree(services); if (services.experimentalFeatures.platformNavEnabled) { - projectNavigationTree.getNavigationTree$().subscribe((navigationTree) => { - serverless.setSideNavComponent(getDefaultNavigationComponent(navigationTree, services)); - }); + const SideNavComponentWithServices = withServicesProvider(SecuritySideNavComponent, services); + serverless.setSideNavComponent(SideNavComponentWithServices); } else { - management.setupCardsNavigation({ enabled: true }); - projectNavigationTree.getChromeNavigationTree$().subscribe((chromeNavigationTree) => { serverless.setNavigation({ navigationTree: chromeNavigationTree }); }); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts index b5c4d746c2af1..f2deadd1fdf25 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/sections/project_settings_links.ts @@ -31,10 +31,6 @@ export const projectSettingsNavLinks: ProjectNavigationLink[] = [ id: ExternalPageName.cloudUsersAndRoles, title: i18n.CLOUD_USERS_ROLES_TITLE, }, - { - id: ExternalPageName.cloudPerformance, - title: i18n.CLOUD_PERFORMANCE_TITLE, - }, { id: ExternalPageName.cloudBilling, title: i18n.CLOUD_BILLING_TITLE, diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts b/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts index 0572dcdedb2ea..edaf40529e16d 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/links/util.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { APP_UI_ID } from '@kbn/security-solution-plugin/common'; +import { APP_UI_ID, SecurityPageName } from '@kbn/security-solution-plugin/common'; +import { ExternalPageName } from './constants'; import type { GetCloudUrl, ProjectPageName } from './types'; export const getNavLinkIdFromProjectPageName = (projectNavLinkId: ProjectPageName): string => { @@ -42,3 +43,16 @@ export const getCloudUrl: GetCloudUrl = (cloudUrlKey, cloud) => { return undefined; } }; + +/** + * Defines the navigation items that should be in the footer of the side navigation. + * @todo Make it a new property in the `NavigationLink` type `position?: 'top' | 'bottom' (default: 'top')` + */ +export const isBottomNavItemId = (id: string) => + id === SecurityPageName.landing || + id === ExternalPageName.devTools || + id === ExternalPageName.management || + id === ExternalPageName.integrationsSecurity || + id === ExternalPageName.cloudUsersAndRoles || + id === ExternalPageName.cloudPerformance || + id === ExternalPageName.cloudBilling; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts index 4653756dc435c..a1a04b9ca236e 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/index.ts @@ -7,16 +7,10 @@ import type { Observable } from 'rxjs'; import { map } from 'rxjs'; -import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; -import type { LinkCategory } from '@kbn/security-solution-navigation'; import type { Services } from '../../common/services'; -import type { ProjectNavLinks, ProjectPageName } from '../links/types'; +import type { ProjectNavLinks } from '../links/types'; import { getFormatChromeProjectNavNodes } from './chrome_navigation_tree'; -import { formatNavigationTree } from './navigation_tree'; -import { CATEGORIES } from '../side_navigation/categories'; - -const projectCategories = CATEGORIES as Array>; /** * This class is temporary until we can remove the chrome navigation tree and use only the formatNavigationTree @@ -29,12 +23,6 @@ export class ProjectNavigationTree { this.projectNavLinks$ = getProjectNavLinks$(); } - public getNavigationTree$(): Observable { - return this.projectNavLinks$.pipe( - map((projectNavLinks) => formatNavigationTree(projectNavLinks, projectCategories)) - ); - } - public getChromeNavigationTree$(): Observable { const formatChromeProjectNavNodes = getFormatChromeProjectNavNodes(this.services); return this.projectNavLinks$.pipe( diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts index f4971a271d7fb..377f01ca0d784 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.test.ts @@ -48,11 +48,12 @@ describe('formatNavigationTree', () => { }); it('should format flat nav nodes', async () => { - const navigationTree = formatNavigationTree([link1]); + const navigationTree = formatNavigationTree([link1], [], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { + id: link1.id, link: chromeNavLink1.id, title: link1.title, }, @@ -65,15 +66,17 @@ describe('formatNavigationTree', () => { type: LinkCategoryType.title, linkIds: [link1Id], }; - const navigationTree = formatNavigationTree([link1], [category]); + const navigationTree = formatNavigationTree([link1], [category], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { title: category.label, id: expect.any(String), + breadcrumbStatus: 'hidden', children: [ { + id: link1.id, link: chromeNavLink1.id, title: link1.title, }, @@ -88,17 +91,24 @@ describe('formatNavigationTree', () => { type: LinkCategoryType.separator, linkIds: [link1Id, link2Id], }; - const navigationTree = formatNavigationTree([link1, link2], [category]); + const navigationTree = formatNavigationTree([link1, link2], [category], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { - link: chromeNavLink1.id, - title: link1.title, - }, - { - link: chromeNavLink2.id, - title: link2.title, + breadcrumbStatus: 'hidden', + children: [ + { + id: link1.id, + link: chromeNavLink1.id, + title: link1.title, + }, + { + id: link2.id, + link: chromeNavLink2.id, + title: link2.title, + }, + ], }, ]); }); @@ -109,15 +119,17 @@ describe('formatNavigationTree', () => { type: LinkCategoryType.title, linkIds: [link1Id, link2Id], }; - const navigationTree = formatNavigationTree([link1], [category]); + const navigationTree = formatNavigationTree([link1], [category], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { title: category.label, id: expect.any(String), + breadcrumbStatus: 'hidden', children: [ { + id: link1.id, link: chromeNavLink1.id, title: link1.title, }, @@ -132,15 +144,17 @@ describe('formatNavigationTree', () => { type: LinkCategoryType.title, linkIds: [link1Id], }; - const navigationTree = formatNavigationTree([link1, link2], [category]); + const navigationTree = formatNavigationTree([link1, link2], [category], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { title: category.label, id: expect.any(String), + breadcrumbStatus: 'hidden', children: [ { + id: link1.id, link: chromeNavLink1.id, title: link1.title, }, @@ -150,11 +164,12 @@ describe('formatNavigationTree', () => { }); it('should format external chrome nav nodes', async () => { - const navigationTree = formatNavigationTree([link3]); + const navigationTree = formatNavigationTree([link3], [], []); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { + id: link3.id, link: chromeNavLink3.id, title: link3.title, }, @@ -162,20 +177,25 @@ describe('formatNavigationTree', () => { }); it('should set nested links', async () => { - const navigationTree = formatNavigationTree([ - { ...link1, links: [{ ...link2, links: [link3] }] }, - ]); + const navigationTree = formatNavigationTree( + [{ ...link1, links: [{ ...link2, links: [link3] }] }], + [], + [] + ); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { + id: link1.id, link: chromeNavLink1.id, title: link1.title, children: [ { + id: link2.id, link: chromeNavLink2.id, title: link2.title, - children: [{ link: chromeNavLink3.id, title: link3.title }], + children: [{ id: link3.id, link: chromeNavLink3.id, title: link3.title }], + renderAs: 'panelOpener', }, ], }, @@ -188,19 +208,22 @@ describe('formatNavigationTree', () => { id: `${APP_UI_ID}:${SecurityPageName.usersEvents}`, // userEvents link is blacklisted }; - const navigationTree = formatNavigationTree([ - { ...link1, id: SecurityPageName.usersEvents }, - link2, - ]); + const navigationTree = formatNavigationTree( + [{ ...link1, id: SecurityPageName.usersEvents }, link2], + [], + [] + ); const securityNode = navigationTree.body?.[0] as GroupDefinition; expect(securityNode?.children).toEqual([ { + id: SecurityPageName.usersEvents, link: chromeNavLinkTest.id, title: link1.title, breadcrumbStatus: 'hidden', }, { + id: link2.id, link: chromeNavLink2.id, title: link2.title, }, diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts index 926e54ad8bb33..2d9ad8c1bb2d5 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts @@ -4,118 +4,66 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { partition } from 'lodash/fp'; import { i18n } from '@kbn/i18n'; -import type { NavigationTreeDefinition } from '@kbn/shared-ux-chrome-navigation'; +import type { + NavigationTreeDefinition, + RootNavigationItemDefinition, +} from '@kbn/shared-ux-chrome-navigation'; import type { AppDeepLinkId, NodeDefinition } from '@kbn/core-chrome-browser'; import type { LinkCategory } from '@kbn/security-solution-navigation'; import { - SecurityPageName, isSeparatorLinkCategory, isTitleLinkCategory, + isAccordionLinkCategory, } from '@kbn/security-solution-navigation'; import type { ProjectNavigationLink, ProjectPageName } from '../links/types'; -import { getNavLinkIdFromProjectPageName } from '../links/util'; +import { getNavLinkIdFromProjectPageName, isBottomNavItemId, isCloudLink } from '../links/util'; import { isBreadcrumbHidden } from './utils'; +import { ExternalPageName } from '../links/constants'; -const SECURITY_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.solution.title', { - defaultMessage: 'Security', -}); -const GET_STARTED_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.getStarted.title', { - defaultMessage: 'Get Started', -}); -const DEV_TOOLS_TITLE = i18n.translate('xpack.securitySolutionServerless.nav.devTools.title', { - defaultMessage: 'Developer tools', -}); -const PROJECT_SETTINGS_TITLE = i18n.translate( - 'xpack.securitySolutionServerless.nav.projectSettings.title', +const SECURITY_PROJECT_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.nav.solution.title', { - defaultMessage: 'Project settings', + defaultMessage: 'Security', } ); export const formatNavigationTree = ( projectNavLinks: ProjectNavigationLink[], - categories?: Readonly>> + bodyCategories: Readonly>>, + footerCategories: Readonly>> ): NavigationTreeDefinition => { - const children = formatNodesFromLinks(projectNavLinks, categories); + const [bodyNavItems, footerNavItems] = partition( + ({ id }) => !isBottomNavItemId(id), + projectNavLinks + ); + + const bodyChildren = addMainLinksPanelOpenerProp( + formatNodesFromLinks(bodyNavItems, bodyCategories) + ); return { body: [ - children - ? { - type: 'navGroup', - id: 'security_project_nav', - title: SECURITY_TITLE, - icon: 'logoSecurity', - breadcrumbStatus: 'hidden', - defaultIsCollapsed: false, - children, - } - : { - type: 'navItem', - id: 'security_project_nav', - title: SECURITY_TITLE, - icon: 'logoSecurity', - breadcrumbStatus: 'hidden', - }, - ], - footer: [ - { - type: 'navItem', - id: 'getStarted', - title: GET_STARTED_TITLE, - link: getNavLinkIdFromProjectPageName(SecurityPageName.landing) as AppDeepLinkId, - icon: 'launch', - }, - { - type: 'navItem', - id: 'devTools', - title: DEV_TOOLS_TITLE, - link: 'dev_tools', - icon: 'editorCodeBlock', - }, { type: 'navGroup', - id: 'project_settings_project_nav', - title: PROJECT_SETTINGS_TITLE, - icon: 'gear', + id: 'security_project_nav', + title: SECURITY_PROJECT_TITLE, + icon: 'logoSecurity', breadcrumbStatus: 'hidden', - children: [ - { - id: 'settings', - children: [ - { - link: 'management', - title: 'Management', - }, - { - link: 'integrations', - }, - { - link: 'fleet', - }, - { - id: 'cloudLinkUserAndRoles', - cloudLink: 'userAndRoles', - }, - { - id: 'cloudLinkBilling', - cloudLink: 'billingAndSub', - }, - ], - }, - ], + defaultIsCollapsed: false, + children: bodyChildren, }, ], + footer: formatFooterNodesFromLinks(footerNavItems, footerCategories), }; }; +// Body + const formatNodesFromLinks = ( projectNavLinks: ProjectNavigationLink[], parentCategories?: Readonly>> -): NodeDefinition[] | undefined => { - if (projectNavLinks.length === 0) { - return undefined; - } +): NodeDefinition[] => { const nodes: NodeDefinition[] = []; if (parentCategories?.length) { parentCategories.forEach((category) => { @@ -124,10 +72,7 @@ const formatNodesFromLinks = ( } else { nodes.push(...formatNodesFromLinksWithoutCategory(projectNavLinks)); } - if (nodes.length === 0) { - return undefined; - } - return nodes as NodeDefinition[]; + return nodes; }; const formatNodesFromLinksWithCategory = ( @@ -137,7 +82,8 @@ const formatNodesFromLinksWithCategory = ( if (!category?.linkIds) { return []; } - if (isTitleLinkCategory(category)) { + + if (category.linkIds) { const children = category.linkIds.reduce((acc, linkId) => { const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); if (projectNavLink != null) { @@ -145,48 +91,135 @@ const formatNodesFromLinksWithCategory = ( } return acc; }, []); - if (children.length === 0) { + if (!children.length) { return []; } + + const id = isTitleLinkCategory(category) ? getCategoryIdFromLabel(category.label) : undefined; + return [ { - id: `category-${category.label.toLowerCase().replace(' ', '_')}`, - title: category.label, - children: children as NodeDefinition[], + id, + ...(isTitleLinkCategory(category) && { title: category.label }), + breadcrumbStatus: 'hidden', + children, }, ]; - } else if (isSeparatorLinkCategory(category)) { - // TODO: Add separator support when implemented in the shared-ux navigation - const categoryProjectNavLinks = category.linkIds.reduce( - (acc, linkId) => { - const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); - if (projectNavLink != null) { - acc.push(projectNavLink); - } - return acc; - }, - [] - ); - return formatNodesFromLinksWithoutCategory(categoryProjectNavLinks); } return []; }; -const formatNodesFromLinksWithoutCategory = (projectNavLinks: ProjectNavigationLink[]) => - projectNavLinks.map((projectNavLink) => - createNodeFromProjectNavLink(projectNavLink) - ) as NodeDefinition[]; +const formatNodesFromLinksWithoutCategory = ( + projectNavLinks: ProjectNavigationLink[] +): NodeDefinition[] => + projectNavLinks.map((projectNavLink) => createNodeFromProjectNavLink(projectNavLink)); const createNodeFromProjectNavLink = (projectNavLink: ProjectNavigationLink): NodeDefinition => { - const { id, title, links, categories } = projectNavLink; + const { id, title, links, categories, disabled } = projectNavLink; const link = getNavLinkIdFromProjectPageName(id); const node: NodeDefinition = { + id, link: link as AppDeepLinkId, title, ...(isBreadcrumbHidden(id) && { breadcrumbStatus: 'hidden' }), + ...(disabled && { sideNavStatus: 'hidden' }), }; if (links?.length) { node.children = formatNodesFromLinks(links, categories); } return node; }; + +// Footer + +const formatFooterNodesFromLinks = ( + projectNavLinks: ProjectNavigationLink[], + parentCategories?: Readonly>> +): RootNavigationItemDefinition[] => { + const nodes: RootNavigationItemDefinition[] = []; + if (parentCategories?.length) { + parentCategories.forEach((category) => { + if (isSeparatorLinkCategory(category)) { + nodes.push( + ...category.linkIds.reduce((acc, linkId) => { + const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); + if (projectNavLink != null) { + acc.push({ + type: 'navItem', + link: getNavLinkIdFromProjectPageName(projectNavLink.id) as AppDeepLinkId, + title: projectNavLink.title, + icon: projectNavLink.sideNavIcon, + }); + } + return acc; + }, []) + ); + } + if (isAccordionLinkCategory(category)) { + nodes.push({ + type: 'navGroup', + id: getCategoryIdFromLabel(category.label), + title: category.label, + icon: category.iconType, + breadcrumbStatus: 'hidden', + defaultIsCollapsed: true, + children: + category.linkIds?.reduce((acc, linkId) => { + const projectNavLink = projectNavLinks.find(({ id }) => id === linkId); + if (projectNavLink != null) { + acc.push({ + title: projectNavLink.title, + ...(isCloudLink(projectNavLink.id) + ? { + cloudLink: getCloudLink(projectNavLink.id), + openInNewTab: true, + } + : { + link: getNavLinkIdFromProjectPageName(projectNavLink.id) as AppDeepLinkId, + }), + }); + } + return acc; + }, []) ?? [], + }); + } + }, []); + } + return nodes; +}; + +// Utils + +const getCategoryIdFromLabel = (label: string): string => + `category-${label.toLowerCase().replace(' ', '_')}`; + +/** + * Adds the `renderAs: 'panelOpener'` prop to the main links that have children + * This function expects all main links to be in nested groups to add the separation between them. + * If these "separator" groups change this function will need to be updated. + */ +const addMainLinksPanelOpenerProp = (nodes: NodeDefinition[]): NodeDefinition[] => + nodes.map((node): NodeDefinition => { + if (node.children?.length) { + return { + ...node, + children: node.children.map((child) => ({ + ...child, + ...(child.children && { renderAs: 'panelOpener' }), + })), + }; + } + return node; + }); + +/** Returns the cloud link entry the default navigation expects */ +const getCloudLink = (id: ProjectPageName) => { + switch (id) { + case ExternalPageName.cloudUsersAndRoles: + return 'userAndRoles'; + case ExternalPageName.cloudBilling: + return 'billingAndSub'; + default: + return undefined; + } +}; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/index.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/index.tsx new file mode 100644 index 0000000000000..53d64a88af6d6 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 React, { Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +const SecuritySideNavComponentLazy = React.lazy(() => import('./project_navigation')); + +export const SecuritySideNavComponent = () => ( + }> + + +); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx new file mode 100644 index 0000000000000..b26700eb8e4b3 --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx @@ -0,0 +1,80 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import { DefaultNavigation, NavigationKibanaProvider } from '@kbn/shared-ux-chrome-navigation'; +import type { + ContentProvider, + PanelComponentProps, +} from '@kbn/shared-ux-chrome-navigation/src/ui/components/panel/types'; +import { SolutionSideNavPanelContent } from '@kbn/security-solution-side-nav/panel'; +import useObservable from 'react-use/lib/useObservable'; +import { useKibana } from '../../common/services'; +import type { ProjectNavigationLink, ProjectPageName } from '../links/types'; +import { useFormattedSideNavItems } from '../side_navigation/use_side_nav_items'; +import { CATEGORIES, FOOTER_CATEGORIES } from '../categories'; +import { formatNavigationTree } from '../navigation_tree/navigation_tree'; + +const getPanelContentProvider = ( + projectNavLinks: ProjectNavigationLink[] +): React.FC => + React.memo(function PanelContentProvider({ selectedNode: { path }, closePanel }) { + const linkId = path[path.length - 1] as ProjectPageName; + const currentPanelItem = projectNavLinks.find((item) => item.id === linkId); + + const { title = '', links = [], categories } = currentPanelItem ?? {}; + const items = useFormattedSideNavItems(links); + + if (items.length === 0) { + return null; + } + return ( + + ); + }); + +const usePanelContentProvider = (projectNavLinks: ProjectNavigationLink[]): ContentProvider => { + return useCallback( + () => ({ + content: getPanelContentProvider(projectNavLinks), + }), + [projectNavLinks] + ); +}; + +export const SecuritySideNavComponent = React.memo(function SecuritySideNavComponent() { + const services = useKibana().services; + const projectNavLinks = useObservable(services.getProjectNavLinks$(), []); + + const navigationTree = useMemo( + () => formatNavigationTree(projectNavLinks, CATEGORIES, FOOTER_CATEGORIES), + [projectNavLinks] + ); + + const panelContentProvider = usePanelContentProvider(projectNavLinks); + + return ( + + + + ); +}); + +// eslint-disable-next-line import/no-default-export +export default SecuritySideNavComponent; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx index d56e98eef3c2d..3d87403b0ff3c 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation.tsx @@ -20,7 +20,7 @@ import { useObservable } from 'react-use'; import { css } from '@emotion/react'; import { partition } from 'lodash/fp'; import { useSideNavItems } from './use_side_nav_items'; -import { CATEGORIES } from './categories'; +import { CATEGORIES, FOOTER_CATEGORIES } from '../categories'; import { getProjectPageNameFromNavLinkId } from '../links/util'; import { useKibana } from '../../common/services'; import { SideNavigationFooter } from './side_navigation_footer'; @@ -39,12 +39,7 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu const { chrome } = useKibana().services; const { euiTheme } = useEuiTheme(); const hasHeaderBanner = useObservable(chrome.hasHeaderBanner$()); - - /** - * TODO: Uncomment this when we have the getIsSideNavCollapsed API available - * const isCollapsed = useObservable(chrome.getIsSideNavCollapsed$()); - */ - const isCollapsed = false; + const isCollapsed = useObservable(chrome.getIsSideNavCollapsed$()); const items = useSideNavItems(); @@ -70,7 +65,7 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu padding-right: ${euiTheme.size.s}; `; - const collapsedNavItems = useMemo(() => { + const collapsedBodyItems = useMemo(() => { return CATEGORIES.reduce((links, category) => { const categoryLinks = items.filter((item) => category.linkIds.includes(item.id)); links.push(...categoryLinks.map((link) => getEuiNavItemFromSideNavItem(link, selectedId))); @@ -93,7 +88,7 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu icon="logoSecurity" iconProps={{ size: 'm' }} data-test-subj="securitySolutionNavHeading" - items={isCollapsed ? collapsedNavItems : undefined} + items={isCollapsed ? collapsedBodyItems : undefined} /> {!isCollapsed && (
@@ -107,7 +102,11 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu )} - + ); diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.test.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.test.tsx index 02e4979c1fba2..fdfd3216d606d 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.test.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.test.tsx @@ -12,6 +12,7 @@ import { SideNavigationFooter } from './side_navigation_footer'; import { ExternalPageName } from '../links/constants'; import { I18nProvider } from '@kbn/i18n-react'; import type { ProjectSideNavItem } from './types'; +import { FOOTER_CATEGORIES } from '../categories'; jest.mock('../../common/services'); @@ -54,9 +55,12 @@ describe('SideNavigationFooter', () => { }); it('should render all the items', () => { - const component = render(, { - wrapper: I18nProvider, - }); + const component = render( + , + { + wrapper: I18nProvider, + } + ); items.forEach((item) => { expect(component.queryByTestId(`solutionSideNavItemLink-${item.id}`)).toBeInTheDocument(); @@ -64,9 +68,16 @@ describe('SideNavigationFooter', () => { }); it('should highlight the active node', () => { - const component = render(, { - wrapper: I18nProvider, - }); + const component = render( + , + { + wrapper: I18nProvider, + } + ); items.forEach((item) => { const isSelected = component @@ -82,9 +93,16 @@ describe('SideNavigationFooter', () => { }); it('should highlight the active node inside the collapsible', () => { - const component = render(, { - wrapper: I18nProvider, - }); + const component = render( + , + { + wrapper: I18nProvider, + } + ); items.forEach((item) => { const isSelected = component @@ -100,9 +118,12 @@ describe('SideNavigationFooter', () => { }); it('should render closed collapsible if it has no active node', () => { - const component = render(, { - wrapper: I18nProvider, - }); + const component = render( + , + { + wrapper: I18nProvider, + } + ); const isOpen = component .queryByTestId('navFooterCollapsible-project-settings') @@ -112,9 +133,16 @@ describe('SideNavigationFooter', () => { }); it('should open collapsible if it has an active node', () => { - const component = render(, { - wrapper: I18nProvider, - }); + const component = render( + , + { + wrapper: I18nProvider, + } + ); const isOpen = component .queryByTestId('navFooterCollapsible-project-settings') diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.tsx index 2c8cf2369c50b..0ed8c1e80f256 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.tsx +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/side_navigation_footer.tsx @@ -8,50 +8,32 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { EuiCollapsibleNavSubItemProps, IconType } from '@elastic/eui'; import { EuiCollapsibleNavItem } from '@elastic/eui'; -import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { ExternalPageName } from '../links/constants'; +import { + isAccordionLinkCategory, + isSeparatorLinkCategory, + type LinkCategory, +} from '@kbn/security-solution-navigation'; import { getNavLinkIdFromProjectPageName } from '../links/util'; import type { ProjectSideNavItem } from './types'; -interface FooterCategory { - type: 'standalone' | 'collapsible'; - title?: string; - icon?: IconType; - linkIds: string[]; -} - -const categories: FooterCategory[] = [ - { type: 'standalone', linkIds: [SecurityPageName.landing, ExternalPageName.devTools] }, - { - type: 'collapsible', - title: 'Project Settings', - icon: 'gear', - linkIds: [ - ExternalPageName.management, - ExternalPageName.integrationsSecurity, - ExternalPageName.cloudUsersAndRoles, - ExternalPageName.cloudPerformance, - ExternalPageName.cloudBilling, - ], - }, -]; - export const SideNavigationFooter: React.FC<{ activeNodeId: string; items: ProjectSideNavItem[]; -}> = ({ activeNodeId, items }) => { + categories: LinkCategory[]; +}> = ({ activeNodeId, items, categories }) => { return ( <> {categories.map((category, index) => { - const categoryItems = category.linkIds.reduce((acc, linkId) => { - const item = items.find(({ id }) => id === linkId); - if (item) { - acc.push(item); - } - return acc; - }, []); + const categoryItems = + category.linkIds?.reduce((acc, linkId) => { + const item = items.find(({ id }) => id === linkId); + if (item) { + acc.push(item); + } + return acc; + }, []) ?? []; - if (category.type === 'standalone') { + if (isSeparatorLinkCategory(category)) { return ( ); } - if (category.type === 'collapsible') { + if (isAccordionLinkCategory(category)) { return ( ); } diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts index 9b61439712221..32945e89765e1 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/side_navigation/use_side_nav_items.ts @@ -6,27 +6,18 @@ */ import { useCallback, useMemo } from 'react'; -import { SecurityPageName, type NavigationLink } from '@kbn/security-solution-navigation'; +import { type NavigationLink } from '@kbn/security-solution-navigation'; import { useGetLinkProps } from '@kbn/security-solution-navigation/links'; import { SolutionSideNavItemPosition } from '@kbn/security-solution-side-nav'; import { useNavLinks } from '../../common/hooks/use_nav_links'; -import { ExternalPageName } from '../links/constants'; import type { ProjectSideNavItem } from './types'; -import type { ProjectPageName } from '../links/types'; +import type { ProjectNavigationLink, ProjectPageName } from '../links/types'; +import { isBottomNavItemId } from '../links/util'; type GetLinkProps = (link: NavigationLink) => { href: string & Partial; }; -const isBottomNavItem = (id: string) => - id === SecurityPageName.landing || - id === ExternalPageName.devTools || - id === ExternalPageName.management || - id === ExternalPageName.integrationsSecurity || - id === ExternalPageName.cloudUsersAndRoles || - id === ExternalPageName.cloudPerformance || - id === ExternalPageName.cloudBilling; - /** * Formats generic navigation links into the shape expected by the `SolutionSideNav` */ @@ -52,7 +43,7 @@ const formatLink = ( id: navLink.id, label: navLink.title, iconType: navLink.sideNavIcon, - position: isBottomNavItem(navLink.id) + position: isBottomNavItemId(navLink.id) ? SolutionSideNavItemPosition.bottom : SolutionSideNavItemPosition.top, ...getLinkProps(navLink), @@ -66,6 +57,15 @@ const formatLink = ( */ export const useSideNavItems = (): ProjectSideNavItem[] => { const navLinks = useNavLinks(); + return useFormattedSideNavItems(navLinks); +}; + +/** + * Returns all the formatted SideNavItems, including external links + */ +export const useFormattedSideNavItems = ( + navLinks: ProjectNavigationLink[] +): ProjectSideNavItem[] => { const getKibanaLinkProps = useGetLinkProps(); const getLinkProps = useCallback(