[Security Solution][Serverless] Left navigation (#156600)

## Summary

closes: https://github.com/elastic/kibana/issues/156414

Adds the basic navigation to the Security Solution project.

- Renders the current navigation hierarchy as in the classic Security.
- Uses the basic styles defined by Core (dark sideNav has been dropped).
- Reuses the Security SideNav package.
- Adds the `setSideNavComponent` API to the Serverless plugin.


![screenshot](https://user-images.githubusercontent.com/17747913/236006911-1d1ccc9b-8a00-41eb-be54-8d6b85be3cb5.png)

### Run project
`yarn serverless-security`


## Next steps
- Add the new features needed in the navigation package to align with
the new Security IA design.
- Update the configuration of the links to display the hierarchy defined
by the new Security IA.
- Add Serverless specific styles to the sideNav in the ServerlesSecurity
plugin.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2023-05-07 19:04:37 +02:00 committed by GitHub
parent 1abd32cb3a
commit c4d233d29a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1103 additions and 9 deletions

3
.github/CODEOWNERS vendored
View file

@ -579,7 +579,7 @@ x-pack/plugins/serverless @elastic/appex-sharedux
x-pack/plugins/serverless_observability @elastic/appex-sharedux
packages/serverless/project_switcher @elastic/appex-sharedux
x-pack/plugins/serverless_search @elastic/appex-sharedux
x-pack/plugins/serverless_security @elastic/appex-sharedux
x-pack/plugins/serverless_security @elastic/security-solution
packages/serverless/storybook/config @elastic/appex-sharedux
packages/serverless/types @elastic/appex-sharedux
test/plugin_functional/plugins/session_notifications @elastic/kibana-core
@ -789,6 +789,7 @@ packages/kbn-yarn-lock-validator @elastic/kibana-operations
#CC# /src/plugins/home/server/services/ @elastic/appex-sharedux
#CC# /src/plugins/home/ @elastic/appex-sharedux
#CC# /x-pack/plugins/reporting/ @elastic/appex-sharedux
#CC# /x-pack/plugins/serverless_security/ @elastic/appex-sharedux
### Observability Plugins

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { BehaviorSubject } from 'rxjs';
import type { NavigationLink } from './common/links/types';
const setupMock = () => ({
resolver: jest.fn(),
});
const startMock = () => ({
getNavLinks$: jest.fn(() => new BehaviorSubject<NavigationLink[]>([])),
setIsSidebarEnabled: jest.fn(),
});
export const securitySolutionMock = {
createSetup: setupMock,
createStart: startMock,
};

View file

@ -310,7 +310,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.registerAppLinks(core, plugins);
return {
navLinks$,
getNavLinks$: () => navLinks$,
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),
};

View file

@ -141,7 +141,7 @@ export interface PluginSetup {
}
export interface PluginStart {
navLinks$: Observable<NavigationLink[]>;
getNavLinks$: () => Observable<NavigationLink[]>;
setIsSidebarEnabled: (isSidebarEnabled: boolean) => void;
}

View file

@ -0,0 +1,16 @@
/*
* 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 { ServerlessPluginStart } from './types';
const startMock = (): ServerlessPluginStart => ({
setSideNavComponent: jest.fn(),
});
export const serverlessMock = {
createStart: startMock,
};

View file

@ -62,7 +62,10 @@ export class ServerlessPlugin
core.chrome.setChromeStyle('project');
management.setIsSidebarEnabled(false);
return {};
return {
setSideNavComponent: (sideNavigationComponent) =>
core.chrome.project.setSideNavComponent(sideNavigationComponent),
};
}
public stop() {}

View file

@ -6,12 +6,14 @@
*/
import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
import { SideNavComponent } from '@kbn/core-chrome-browser/src/project_navigation';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginStart {}
export interface ServerlessPluginStart {
setSideNavComponent: (navigation: SideNavComponent) => void;
}
export interface ServerlessPluginSetupDependencies {
management: ManagementSetup;

View file

@ -21,5 +21,6 @@
"@kbn/serverless-project-switcher",
"@kbn/serverless-types",
"@kbn/utils",
"@kbn/core-chrome-browser",
]
}

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../',
projects: ['<rootDir>/x-pack/plugins/serverless_security/public/*/jest.config.js'],
};

View file

@ -1,7 +1,7 @@
{
"type": "plugin",
"id": "@kbn/serverless-security",
"owner": "@elastic/appex-sharedux",
"owner": "@elastic/security-solution",
"description": "Serverless customizations for security.",
"plugin": {
"id": "serverlessSecurity",

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/serverless_security/public/components'],
testMatch: [
'<rootDir>/x-pack/plugins/serverless_security/public/components/**/*.test.{js,mjs,ts,tsx}',
],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/serverless_security/public/components',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/serverless_security/public/components/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/*.d.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/*.config.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/components/index.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,26 @@
/*
* 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 from 'react';
import { CoreStart } from '@kbn/core/public';
import type {
SideNavComponent,
SideNavCompProps,
} from '@kbn/core-chrome-browser/src/project_navigation';
import { ServerlessSecurityPluginStartDependencies } from '../../types';
import { SecuritySideNavigation } from './side_navigation';
import { KibanaServicesProvider } from '../../services';
export const getSecuritySideNavComponent = (
core: CoreStart,
pluginsStart: ServerlessSecurityPluginStartDependencies
): SideNavComponent => {
return (_props: SideNavCompProps) => (
<KibanaServicesProvider core={core} pluginsStart={pluginsStart}>
<SecuritySideNavigation />
</KibanaServicesProvider>
);
};

View file

@ -0,0 +1,101 @@
/*
* 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 from 'react';
import { render } from '@testing-library/react';
import { SecuritySideNavigation } from './side_navigation';
import { useSideNavItems, useSideNavSelectedId } from '../../hooks/use_side_nav_items';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import { KibanaServicesProvider } from '../../services.mock';
jest.mock('../../hooks/use_side_nav_items');
const mockUseSideNavItems = useSideNavItems as jest.Mock;
const mockUseSideNavSelectedId = useSideNavSelectedId as jest.Mock;
const mockSolutionSideNav = jest.fn((_props: unknown) => <div data-test-subj="solutionSideNav" />);
jest.mock('@kbn/security-solution-side-nav', () => ({
SolutionSideNav: (props: unknown) => mockSolutionSideNav(props),
}));
const sideNavItems = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '/dashboards',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '/alerts',
onClick: jest.fn(),
},
];
const sideNavFooterItems = [
{
id: SecurityPageName.administration,
label: 'Manage',
href: '/administration',
onClick: jest.fn(),
},
];
mockUseSideNavItems.mockReturnValue(sideNavItems);
mockUseSideNavSelectedId.mockReturnValue(SecurityPageName.alerts);
describe('SecuritySideNavigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render loading when not items received', () => {
mockUseSideNavItems.mockReturnValueOnce([]);
const component = render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(component.queryByTestId('sideNavLoader')).toBeInTheDocument();
});
it('should not render loading when items received', () => {
const component = render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(component.queryByTestId('sideNavLoader')).not.toBeInTheDocument();
});
it('should render the SideNav when items received', () => {
const component = render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(component.queryByTestId('solutionSideNav')).toBeInTheDocument();
});
it('should pass item props to the SolutionSideNav component', () => {
render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
items: sideNavItems,
})
);
});
it('should pass footerItems props to the SolutionSideNav component', () => {
mockUseSideNavItems.mockReturnValueOnce(sideNavFooterItems);
render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
footerItems: sideNavFooterItems,
})
);
});
it('should selectedId the SolutionSideNav component', () => {
render(<SecuritySideNavigation />, { wrapper: KibanaServicesProvider });
expect(mockSolutionSideNav).toHaveBeenCalledWith(
expect.objectContaining({
selectedId: SecurityPageName.alerts,
})
);
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 from 'react';
import { EuiLoadingSpinner, useEuiTheme } from '@elastic/eui';
import { SolutionNav } from '@kbn/shared-ux-page-solution-nav';
import { SolutionSideNav } from '@kbn/security-solution-side-nav';
import {
usePartitionFooterNavItems,
useSideNavItems,
useSideNavSelectedId,
} from '../../hooks/use_side_nav_items';
export const SecuritySideNavigation: React.FC = () => {
const { euiTheme } = useEuiTheme();
const sideNavItems = useSideNavItems();
const selectedId = useSideNavSelectedId(sideNavItems);
const [items, footerItems] = usePartitionFooterNavItems(sideNavItems);
const isLoading = items.length === 0 && footerItems.length === 0;
return isLoading ? (
<EuiLoadingSpinner size="m" data-test-subj="sideNavLoader" />
) : (
<SolutionNav
canBeCollapsed={false}
name={'Security'}
icon={'logoSecurity'}
children={
<SolutionSideNav
items={items}
footerItems={footerItems}
selectedId={selectedId}
panelTopOffset={`calc(${euiTheme.size.l} * 2)`}
/>
}
closeFlyoutButtonPosition={'inside'}
/>
);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { GetLinkProps } from '../use_link_props';
export const getLinkProps = jest.fn(() => ({
href: '/test-href',
onClick: jest.fn(),
}));
export const useLinkProps: GetLinkProps = getLinkProps;
export const useGetLinkProps: () => GetLinkProps = jest.fn(() => getLinkProps);

View file

@ -0,0 +1,21 @@
/*
* 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 { SolutionSideNavItem } from '@kbn/security-solution-side-nav';
const { usePartitionFooterNavItems: originalUsePartitionFooterNavItems } =
jest.requireActual('../use_side_nav_items');
export const useSideNavItems = jest.fn((): SolutionSideNavItem[] => []);
export const usePartitionFooterNavItems = jest.fn(
(sideNavItems: SolutionSideNavItem[]): [SolutionSideNavItem[], SolutionSideNavItem[]] =>
// Same implementation as original for convenience. Can be overridden in tests if needed
originalUsePartitionFooterNavItems(sideNavItems)
);
export const useSideNavSelectedId = jest.fn((_sideNavItems: SolutionSideNavItem[]): string => '');

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/serverless_security/public/hooks'],
testMatch: [
'<rootDir>/x-pack/plugins/serverless_security/public/hooks/**/*.test.{js,mjs,ts,tsx}',
],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/serverless_security/public/hooks',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/serverless_security/public/hooks/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/*.d.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/*.config.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/hooks/index.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,156 @@
/*
* 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 { MouseEvent } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { APP_UI_ID, SecurityPageName } from '@kbn/security-solution-plugin/common';
import { KibanaServicesProvider, servicesMocks } from '../services.mock';
import { useGetLinkProps, useLinkProps } from './use_link_props';
const { getUrlForApp: mockGetUrlForApp, navigateToUrl: mockNavigateToUrl } =
servicesMocks.application;
const href = '/app/security/test';
mockGetUrlForApp.mockReturnValue(href);
describe('useLinkProps', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return link props', async () => {
const { result } = renderHook(useLinkProps, { wrapper: KibanaServicesProvider });
const linkProps = result.current;
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: undefined,
path: undefined,
});
});
it('should call navigate when clicked normally', async () => {
const ev = { preventDefault: jest.fn() } as unknown as MouseEvent;
const { result } = renderHook(useLinkProps, { wrapper: KibanaServicesProvider });
const { onClick } = result.current;
onClick(ev);
expect(mockNavigateToUrl).toHaveBeenCalledTimes(1);
expect(mockNavigateToUrl).toHaveBeenCalledWith(href);
});
it('should not call navigate when clicked with modifiers', async () => {
const ev = { preventDefault: jest.fn(), ctrlKey: true } as unknown as MouseEvent;
const { result } = renderHook(useLinkProps, { wrapper: KibanaServicesProvider });
const { onClick } = result.current;
onClick(ev);
expect(mockNavigateToUrl).not.toHaveBeenCalled();
});
it('should return link props passing deepLink', async () => {
const { result } = renderHook(useLinkProps, {
wrapper: KibanaServicesProvider,
initialProps: { deepLinkId: SecurityPageName.alerts },
});
const linkProps = result.current;
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: SecurityPageName.alerts,
path: undefined,
});
});
it('should return link props passing deepLink and path', async () => {
const { result } = renderHook(useLinkProps, {
wrapper: KibanaServicesProvider,
initialProps: { deepLinkId: SecurityPageName.alerts, path: '/test' },
});
const linkProps = result.current;
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: SecurityPageName.alerts,
path: '/test',
});
});
});
describe('useGetLinkProps', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return link props', async () => {
const { result } = renderHook(useGetLinkProps, { wrapper: KibanaServicesProvider });
const linkProps = result.current({});
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: undefined,
path: undefined,
});
});
it('should call navigate when clicked normally', async () => {
const ev = { preventDefault: jest.fn() } as unknown as MouseEvent;
const { result } = renderHook(useGetLinkProps, { wrapper: KibanaServicesProvider });
const { onClick } = result.current({});
onClick(ev);
expect(mockNavigateToUrl).toHaveBeenCalledTimes(1);
expect(mockNavigateToUrl).toHaveBeenCalledWith(href);
});
it('should not call navigate when clicked with modifiers', async () => {
const ev = { preventDefault: jest.fn(), ctrlKey: true } as unknown as MouseEvent;
const { result } = renderHook(useGetLinkProps, { wrapper: KibanaServicesProvider });
const { onClick } = result.current({});
onClick(ev);
expect(mockNavigateToUrl).not.toHaveBeenCalled();
});
it('should return link props passing deepLink', async () => {
const { result } = renderHook(useGetLinkProps, { wrapper: KibanaServicesProvider });
const linkProps = result.current({ deepLinkId: SecurityPageName.alerts });
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: SecurityPageName.alerts,
path: undefined,
});
});
it('should return link props passing deepLink and path', async () => {
const { result } = renderHook(useGetLinkProps, { wrapper: KibanaServicesProvider });
const linkProps = result.current({ deepLinkId: SecurityPageName.alerts, path: '/test' });
expect(linkProps).toEqual({ href, onClick: expect.any(Function) });
expect(mockGetUrlForApp).toHaveBeenCalledTimes(1);
expect(mockGetUrlForApp).toHaveBeenCalledWith(APP_UI_ID, {
deepLinkId: SecurityPageName.alerts,
path: '/test',
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { APP_UI_ID, type SecurityPageName } from '@kbn/security-solution-plugin/common';
import { useMemo, useCallback, type MouseEventHandler, type MouseEvent } from 'react';
import { useKibana, type Services } from '../services';
interface LinkProps {
onClick: MouseEventHandler;
href: string;
}
interface GetLinkPropsParams {
deepLinkId?: SecurityPageName;
path?: string;
appId?: string;
onClick?: MouseEventHandler;
}
export type GetLinkProps = (params: GetLinkPropsParams) => LinkProps;
export const useLinkProps: GetLinkProps = (props) => {
const { application } = useKibana().services;
return useMemo(() => getLinkProps({ ...props, application }), [application, props]);
};
export const useGetLinkProps: () => GetLinkProps = () => {
const { application } = useKibana().services;
return useCallback<GetLinkProps>(
(props) => getLinkProps({ ...props, application }),
[application]
);
};
const getLinkProps = ({
deepLinkId,
path,
onClick: onClickProps,
appId = APP_UI_ID,
application,
}: GetLinkPropsParams & { application: Services['application'] }): LinkProps => {
const { getUrlForApp, navigateToUrl } = application;
const url = getUrlForApp(appId, { deepLinkId, path });
return {
href: url,
onClick: (ev) => {
if (isModifiedEvent(ev)) {
return;
}
ev.preventDefault();
navigateToUrl(url);
if (onClickProps) {
onClickProps(ev);
}
},
};
};
const isModifiedEvent = (event: MouseEvent) =>
event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;

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 { useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { useKibana } from '../services';
export const useNavLinks = () => {
const { securitySolution } = useKibana().services;
const { getNavLinks$ } = securitySolution;
const navLinks$ = useMemo(() => getNavLinks$(), [getNavLinks$]);
return useObservable(navLinks$, []);
};

View file

@ -0,0 +1,302 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import {
usePartitionFooterNavItems,
useSideNavItems,
useSideNavSelectedId,
} from './use_side_nav_items';
import { BehaviorSubject } from 'rxjs';
import type { NavigationLink } from '@kbn/security-solution-plugin/public/common/links/types';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import { KibanaServicesProvider, servicesMocks } from '../services.mock';
jest.mock('./use_link_props');
const mockNavLinks = jest.fn((): NavigationLink[] => []);
servicesMocks.securitySolution.getNavLinks$.mockImplementation(
() => new BehaviorSubject(mockNavLinks())
);
const mockUseLocation = jest.fn(() => ({ pathname: '/' }));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => mockUseLocation(),
}));
describe('useSideNavItems', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty items', async () => {
const { result } = renderHook(useSideNavItems, { wrapper: KibanaServicesProvider });
const items = result.current;
expect(items).toEqual([]);
expect(servicesMocks.securitySolution.getNavLinks$).toHaveBeenCalledTimes(1);
});
it('should return main items', async () => {
mockNavLinks.mockReturnValueOnce([
{ id: SecurityPageName.alerts, title: 'Alerts' },
{ id: SecurityPageName.case, title: 'Cases' },
]);
const { result } = renderHook(useSideNavItems, { wrapper: KibanaServicesProvider });
const items = result.current;
expect(items).toEqual([
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: expect.any(String),
onClick: expect.any(Function),
},
{
id: SecurityPageName.case,
label: 'Cases',
href: expect.any(String),
onClick: expect.any(Function),
},
]);
});
it('should return secondary items', async () => {
mockNavLinks.mockReturnValueOnce([
{
id: SecurityPageName.dashboards,
title: 'Dashboards',
links: [{ id: SecurityPageName.detectionAndResponse, title: 'Detection & Response' }],
},
]);
const { result } = renderHook(useSideNavItems, { wrapper: KibanaServicesProvider });
const items = result.current;
expect(items).toEqual([
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: expect.any(String),
onClick: expect.any(Function),
items: [
{
id: SecurityPageName.detectionAndResponse,
label: 'Detection & Response',
href: expect.any(String),
onClick: expect.any(Function),
},
],
},
]);
});
it('should return get started link', async () => {
mockNavLinks.mockReturnValueOnce([
{
id: SecurityPageName.landing,
title: 'Get Started',
},
]);
const { result } = renderHook(useSideNavItems, { wrapper: KibanaServicesProvider });
const items = result.current;
expect(items).toEqual([
{
id: SecurityPageName.landing,
label: 'GET STARTED',
href: expect.any(String),
onClick: expect.any(Function),
labelSize: 'xs',
iconType: 'launch',
appendSeparator: true,
},
]);
});
});
describe('usePartitionFooterNavItems', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should partition main items only', async () => {
const mainInputItems = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: mainInputItems,
});
const [items, footerItems] = result.current;
expect(items).toEqual(mainInputItems);
expect(footerItems).toEqual([]);
});
it('should partition footer items only', async () => {
const footerInputItems = [
{
id: SecurityPageName.landing,
label: 'GET STARTED',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.administration,
label: 'Manage',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: footerInputItems,
});
const [items, footerItems] = result.current;
expect(items).toEqual([]);
expect(footerItems).toEqual(footerInputItems);
});
it('should partition main and footer items', async () => {
const mainInputItems = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '',
onClick: jest.fn(),
},
];
const footerInputItems = [
{
id: SecurityPageName.landing,
label: 'GET STARTED',
href: '',
onClick: jest.fn(),
},
{
id: SecurityPageName.administration,
label: 'Manage',
href: '',
onClick: jest.fn(),
},
];
const { result } = renderHook(usePartitionFooterNavItems, {
initialProps: [...mainInputItems, ...footerInputItems],
});
const [items, footerItems] = result.current;
expect(items).toEqual(mainInputItems);
expect(footerItems).toEqual(footerInputItems);
});
});
describe('useSideNavSelectedId', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return empty string when no item selected', async () => {
const items = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '/app/security/dashboards',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '/app/security/alerts',
onClick: jest.fn(),
},
];
const { result } = renderHook(useSideNavSelectedId, {
wrapper: KibanaServicesProvider,
initialProps: items,
});
const selectedId = result.current;
expect(selectedId).toEqual('');
});
it('should return the item with path selected', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/app/security/alerts' });
const items = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '/app/security/dashboards',
onClick: jest.fn(),
},
{
id: SecurityPageName.alerts,
label: 'Alerts',
href: '/app/security/alerts',
onClick: jest.fn(),
},
];
const { result } = renderHook(useSideNavSelectedId, {
wrapper: KibanaServicesProvider,
initialProps: items,
});
const selectedId = result.current;
expect(selectedId).toEqual(SecurityPageName.alerts);
});
it('should return the main item when nested path selected', async () => {
mockUseLocation.mockReturnValueOnce({ pathname: '/app/security/detection_response' });
const items = [
{
id: SecurityPageName.dashboards,
label: 'Dashboards',
href: '/app/security/dashboards',
onClick: jest.fn(),
items: [
{
id: SecurityPageName.detectionAndResponse,
label: 'Detection & Response',
href: '/app/security/detection_response',
onClick: jest.fn(),
},
],
},
];
const { result } = renderHook(useSideNavSelectedId, {
wrapper: KibanaServicesProvider,
initialProps: items,
});
const selectedId = result.current;
expect(selectedId).toEqual(SecurityPageName.dashboards);
});
});

View file

@ -0,0 +1,143 @@
/*
* 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 { useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { partition } from 'lodash/fp';
import { SecurityPageName } from '@kbn/security-solution-plugin/common';
import type { SolutionSideNavItem } from '@kbn/security-solution-side-nav';
import { useKibana } from '../services';
import { useGetLinkProps } from './use_link_props';
import { useNavLinks } from './use_nav_links';
const isFooterNavItem = (id: string) =>
id === SecurityPageName.landing || id === SecurityPageName.administration;
const isGetStartedNavItem = (id: string) => id === SecurityPageName.landing;
// DFS for the sideNavItem matching the current `pathname`, returns all item hierarchy when found
const findItemsByPath = (
sideNavItems: SolutionSideNavItem[],
pathname: string
): SolutionSideNavItem[] => {
for (const sideNavItem of sideNavItems) {
if (sideNavItem.items?.length) {
const found = findItemsByPath(sideNavItem.items, pathname);
if (found.length) {
found.unshift(sideNavItem);
return found;
}
}
if (matchPath(pathname, { path: sideNavItem.href })) {
return [sideNavItem];
}
}
return [];
};
/**
* Returns all the formatted SideNavItems, including external links
*/
export const useSideNavItems = (): SolutionSideNavItem[] => {
const navLinks = useNavLinks();
const getLinkProps = useGetLinkProps();
const securitySideNavItems = useMemo(
() =>
navLinks.reduce<SolutionSideNavItem[]>((items, navLink) => {
if (navLink.disabled) {
return items;
}
if (isGetStartedNavItem(navLink.id)) {
items.push({
id: navLink.id,
label: navLink.title.toUpperCase(),
...getLinkProps({ deepLinkId: navLink.id }),
labelSize: 'xs',
iconType: 'launch',
appendSeparator: true,
});
} else {
// default sideNavItem formatting
items.push({
id: navLink.id,
label: navLink.title,
...getLinkProps({ deepLinkId: navLink.id }),
...(navLink.categories?.length && { categories: navLink.categories }),
...(navLink.links?.length && {
items: navLink.links.reduce<SolutionSideNavItem[]>((acc, current) => {
if (!current.disabled) {
acc.push({
id: current.id,
label: current.title,
description: current.description,
isBeta: current.isBeta,
betaOptions: current.betaOptions,
...getLinkProps({ deepLinkId: current.id }),
});
}
return acc;
}, []),
}),
});
}
return items;
}, []),
[getLinkProps, navLinks]
);
const sideNavItems = useAddExternalSideNavItems(securitySideNavItems);
return sideNavItems;
};
/**
* @param securitySideNavItems the sideNavItems for Security pages
* @returns sideNavItems with Security and external links
*/
const useAddExternalSideNavItems = (securitySideNavItems: SolutionSideNavItem[]) => {
const sideNavItemsWithExternals = useMemo(
() => [
...securitySideNavItems,
// TODO: add external links. e.g.:
// {
// id: 'ml',
// label: 'Machine Learning Jobs',
// ...getLinkProps({ appId: 'ml', path: '/jobs' }),
// links: [...]
// },
],
[securitySideNavItems]
);
return sideNavItemsWithExternals;
};
/**
* Partitions the sideNavItems into main and footer SideNavItems
* @param sideNavItems array for all SideNavItems
* @returns `[items, footerItems]` to be used in the side navigation component
*/
export const usePartitionFooterNavItems = (
sideNavItems: SolutionSideNavItem[]
): [SolutionSideNavItem[], SolutionSideNavItem[]] =>
useMemo(() => partition((item) => !isFooterNavItem(item.id), sideNavItems), [sideNavItems]);
/**
* Returns the selected item id, which is the root item in the links hierarchy
*/
export const useSideNavSelectedId = (sideNavItems: SolutionSideNavItem[]): string => {
const { http } = useKibana().services;
const { pathname } = useLocation();
const selectedId: string = useMemo(() => {
const [rootNavItem] = findItemsByPath(sideNavItems, http.basePath.prepend(pathname));
return rootNavItem?.id ?? '';
}, [sideNavItems, pathname, http]);
return selectedId;
};

View file

@ -0,0 +1,27 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
/** all nested directories have their own Jest config file */
testMatch: ['<rootDir>/x-pack/plugins/serverless_security/public/*.test.{js,mjs,ts,tsx}'],
roots: ['<rootDir>/x-pack/plugins/serverless_security/public'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/serverless_security/public',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/serverless_security/public/**/*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*',
'!<rootDir>/x-pack/plugins/serverless_security/public/*mock*.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/*.test.{ts,tsx}',
'!<rootDir>/x-pack/plugins/serverless_security/public/*.d.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/*.config.ts',
'!<rootDir>/x-pack/plugins/serverless_security/public/index.{js,ts,tsx}',
],
};

View file

@ -6,6 +6,7 @@
*/
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { getSecuritySideNavComponent } from './components/side_navigation';
import {
ServerlessSecurityPluginSetup,
ServerlessSecurityPluginStart,
@ -30,10 +31,14 @@ export class ServerlessSecurityPlugin
}
public start(
_core: CoreStart,
{ securitySolution }: ServerlessSecurityPluginStartDependencies
core: CoreStart,
startDeps: ServerlessSecurityPluginStartDependencies
): ServerlessSecurityPluginStart {
const { securitySolution, serverless } = startDeps;
securitySolution.setIsSidebarEnabled(false);
serverless.setSideNavComponent(getSecuritySideNavComponent(core, startDeps));
return {};
}

View file

@ -0,0 +1,26 @@
/*
* 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 from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import { serverlessMock } from '@kbn/serverless/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { securitySolutionMock } from '@kbn/security-solution-plugin/public/mocks';
export const servicesMocks = {
...coreMock.createStart(),
serverless: serverlessMock.createStart(),
security: securityMock.createStart(),
securitySolution: securitySolutionMock.createStart(),
};
export const KibanaServicesProvider = React.memo(({ children }) => (
<I18nProvider>
<KibanaContextProvider services={servicesMocks}>{children}</KibanaContextProvider>
</I18nProvider>
));

View file

@ -0,0 +1,26 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import React from 'react';
import {
KibanaContextProvider,
useKibana as useKibanaReact,
} from '@kbn/kibana-react-plugin/public';
import type { ServerlessSecurityPluginStartDependencies } from './types';
export type Services = CoreStart & ServerlessSecurityPluginStartDependencies;
export const KibanaServicesProvider: React.FC<{
core: CoreStart;
pluginsStart: ServerlessSecurityPluginStartDependencies;
}> = ({ core, pluginsStart, children }) => {
const services: Services = { ...core, ...pluginsStart };
return <KibanaContextProvider services={services}>{children}</KibanaContextProvider>;
};
export const useKibana = () => useKibanaReact<Services>();

View file

@ -20,5 +20,10 @@
"@kbn/security-plugin",
"@kbn/security-solution-plugin",
"@kbn/serverless",
"@kbn/shared-ux-page-solution-nav",
"@kbn/security-solution-side-nav",
"@kbn/kibana-react-plugin",
"@kbn/core-chrome-browser",
"@kbn/i18n-react",
]
}