[Security Solution][Serverless] Implements panelContentProvider on the DefaultNavigation (#169270)

## Summary

Implements the `panelContentProvider` for the `DefaultNavigation`
component, so the content of the panels when open is provided by the
Security Solution plugin.

In order to test it, the experimental flag needs to be enabled.
In `config/serverless.security.yml` add:

```
xpack.securitySolutionServerless.enableExperimental: ['platformNavEnabled']
```

## Screenshot

<img width="1718" alt="Captura de pantalla 2023-10-18 a les 18 38 04"
src="5022a7d9-c619-4dbb-87cf-ee3ed0090853">

(The vertical separation of the main nav links is still not implemented
by the DefaultNavigation)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2023-10-23 10:36:07 +02:00 committed by GitHub
parent 17c78db794
commit a8a22c39b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 455 additions and 305 deletions

View file

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

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { SolutionSideNavPanel } from './src/solution_side_nav_panel';
export { SolutionSideNavPanelContent } from './src/solution_side_nav_panel';

View file

@ -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<SolutionSideNavPanelProps> = 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<SolutionSideNavPanelProps> = React.m
paddingSize="m"
data-test-subj="solutionSideNavPanel"
>
<EuiFlexGroup direction="column" gutterSize="m" alignItems="flexStart">
<EuiFlexItem>
<EuiTitle size="xs" className={titleClasses}>
<strong>{title}</strong>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem style={{ width: '100%' }}>
{categories ? (
<SolutionSideNavPanelCategories
categories={categories}
items={items}
onClose={onClose}
/>
) : (
<SolutionSideNavPanelItems items={items} onClose={onClose} />
)}
</EuiFlexItem>
</EuiFlexGroup>
<SolutionSideNavPanelContent
title={title}
categories={categories}
items={items}
onClose={onClose}
/>
</EuiPanel>
</EuiOutsideClickDetector>
</EuiFocusTrap>
@ -137,6 +126,33 @@ export const SolutionSideNavPanel: React.FC<SolutionSideNavPanelProps> = React.m
}
);
export const SolutionSideNavPanelContent: React.FC<SolutionSideNavPanelContentProps> = React.memo(
function SolutionSideNavPanelContent({ title, onClose, categories, items }) {
const { euiTheme } = useEuiTheme();
const titleClasses = classNames(SolutionSideNavTitleStyles(euiTheme));
return (
<EuiFlexGroup direction="column" gutterSize="m" alignItems="flexStart">
<EuiFlexItem>
<EuiTitle size="xs" className={titleClasses}>
<strong>{title}</strong>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem style={{ width: '100%' }}>
{categories ? (
<SolutionSideNavPanelCategories
categories={categories}
items={items}
onClose={onClose}
/>
) : (
<SolutionSideNavPanelItems items={items} onClose={onClose} />
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
interface SolutionSideNavPanelCategoriesProps {
categories: LinkCategories;
items: SolutionSideNavItem[];

View file

@ -20,9 +20,5 @@ export const TelemetryContextProvider: FC<TelemetryProviderProps> = ({ children,
};
export const useTelemetryContext = () => {
const context = useContext(TelemetryContext);
if (!context) {
throw new Error('No TelemetryContext found.');
}
return context;
return useContext(TelemetryContext) ?? {};
};

View file

@ -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<SeparatorLinkCategory<ProjectPageName>> = [
{
type: LinkCategoryType.separator,
linkIds: [ExternalPageName.discover, SecurityPageName.dashboards],
@ -43,3 +46,24 @@ export const CATEGORIES: SeparatorLinkCategory[] = [
linkIds: [SecurityPageName.mlLanding],
},
];
export const FOOTER_CATEGORIES: Array<LinkCategory<ProjectPageName>> = [
{
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,
],
},
];

View file

@ -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 (
<NavigationKibanaProvider
core={services}
serverless={services.serverless}
cloud={services.cloud}
>
<DefaultNavigation
navigationTree={navigationTree}
dataTestSubj="securitySolutionSideNav"
/>
</NavigationKibanaProvider>
);
}),
})
)
);
export const getDefaultNavigationComponent = (
navigationTree: NavigationTreeDefinition,
services: Services
): SideNavComponent =>
function SecuritySideNavComponent() {
return (
<Suspense fallback={<EuiLoadingSpinner size="m" />}>
<SecurityDefaultNavigationLazy navigationTree={navigationTree} services={services} />
</Suspense>
);
};

View file

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

View file

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

View file

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

View file

@ -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<LinkCategory<ProjectPageName>>;
/**
* 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<NavigationTreeDefinition> {
return this.projectNavLinks$.pipe(
map((projectNavLinks) => formatNavigationTree(projectNavLinks, projectCategories))
);
}
public getChromeNavigationTree$(): Observable<ChromeProjectNavigationNode[]> {
const formatChromeProjectNavNodes = getFormatChromeProjectNavNodes(this.services);
return this.projectNavLinks$.pipe(

View file

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

View file

@ -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<Array<LinkCategory<ProjectPageName>>>
bodyCategories: Readonly<Array<LinkCategory<ProjectPageName>>>,
footerCategories: Readonly<Array<LinkCategory<ProjectPageName>>>
): 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<Array<LinkCategory<ProjectPageName>>>
): 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<NodeDefinition[]>((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<ProjectNavigationLink[]>(
(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<Array<LinkCategory<ProjectPageName>>>
): RootNavigationItemDefinition[] => {
const nodes: RootNavigationItemDefinition[] = [];
if (parentCategories?.length) {
parentCategories.forEach((category) => {
if (isSeparatorLinkCategory(category)) {
nodes.push(
...category.linkIds.reduce<RootNavigationItemDefinition[]>((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<NodeDefinition[]>((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;
}
};

View file

@ -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 = () => (
<Suspense fallback={<EuiLoadingSpinner size="s" />}>
<SecuritySideNavComponentLazy />
</Suspense>
);

View file

@ -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<PanelComponentProps> =>
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 (
<SolutionSideNavPanelContent
title={title}
items={items}
categories={categories}
onClose={closePanel}
/>
);
});
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 (
<NavigationKibanaProvider
core={services}
serverless={services.serverless}
cloud={services.cloud}
>
<DefaultNavigation
dataTestSubj="securitySolutionSideNav"
navigationTree={navigationTree}
panelContentProvider={panelContentProvider}
/>
</NavigationKibanaProvider>
);
});
// eslint-disable-next-line import/no-default-export
export default SecuritySideNavComponent;

View file

@ -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<EuiCollapsibleNavItemProps[]>((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 && (
<div css={bodyStyle}>
@ -107,7 +102,11 @@ export const SecuritySideNavigation: SideNavComponent = React.memo(function Secu
)}
</EuiCollapsibleNavBeta.Body>
<EuiCollapsibleNavBeta.Footer>
<SideNavigationFooter activeNodeId={activeNodeId} items={footerItems} />
<SideNavigationFooter
activeNodeId={activeNodeId}
items={footerItems}
categories={FOOTER_CATEGORIES}
/>
</EuiCollapsibleNavBeta.Footer>
</>
);

View file

@ -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(<SideNavigationFooter items={items} activeNodeId={''} />, {
wrapper: I18nProvider,
});
const component = render(
<SideNavigationFooter items={items} activeNodeId={''} categories={FOOTER_CATEGORIES} />,
{
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(<SideNavigationFooter items={items} activeNodeId={'dev_tools'} />, {
wrapper: I18nProvider,
});
const component = render(
<SideNavigationFooter
items={items}
activeNodeId={'dev_tools'}
categories={FOOTER_CATEGORIES}
/>,
{
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(<SideNavigationFooter items={items} activeNodeId={'management'} />, {
wrapper: I18nProvider,
});
const component = render(
<SideNavigationFooter
items={items}
activeNodeId={'management'}
categories={FOOTER_CATEGORIES}
/>,
{
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(<SideNavigationFooter items={items} activeNodeId={''} />, {
wrapper: I18nProvider,
});
const component = render(
<SideNavigationFooter items={items} activeNodeId={''} categories={FOOTER_CATEGORIES} />,
{
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(<SideNavigationFooter items={items} activeNodeId={'management'} />, {
wrapper: I18nProvider,
});
const component = render(
<SideNavigationFooter
items={items}
activeNodeId={'management'}
categories={FOOTER_CATEGORIES}
/>,
{
wrapper: I18nProvider,
}
);
const isOpen = component
.queryByTestId('navFooterCollapsible-project-settings')

View file

@ -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<ProjectSideNavItem[]>((acc, linkId) => {
const item = items.find(({ id }) => id === linkId);
if (item) {
acc.push(item);
}
return acc;
}, []);
const categoryItems =
category.linkIds?.reduce<ProjectSideNavItem[]>((acc, linkId) => {
const item = items.find(({ id }) => id === linkId);
if (item) {
acc.push(item);
}
return acc;
}, []) ?? [];
if (category.type === 'standalone') {
if (isSeparatorLinkCategory(category)) {
return (
<SideNavigationFooterStandalone
key={index}
@ -60,14 +42,14 @@ export const SideNavigationFooter: React.FC<{
/>
);
}
if (category.type === 'collapsible') {
if (isAccordionLinkCategory(category)) {
return (
<SideNavigationFooterCollapsible
key={index}
title={category.title ?? ''}
title={category.label ?? ''}
items={categoryItems}
activeNodeId={activeNodeId}
icon={category.icon}
icon={category.iconType}
/>
);
}

View file

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