[Security Solution] Unified IA Project Navigation (#161667)

## Summary

Implementation of serverless-specific pages within the Unified IA
Navigation.

#### Links implemented:

- `Machine Learning`
  - Landing page created on serverless only
  - All links in the landing page go to `/ml` app
  
- `Dev Tools` 
  - Links directly to `/dev_tools` app


![snapshot](bd53c796-02df-4c3a-88e4-0fa043b896cd)

#### Links not implemented:
```// TODO: in a follow-up PR```

- Project Settings
  - Change the _Settings_ name by _Project Settings_
  - Modify the landing page items according to the design

## Changes

### Plugin contract changes

The Machine Learning landing page is the first page that is only available on serverless and should not exist in ess (there are more of this kind in the pipeline), so this PR implements the foundations to enable the _security_solution_serverless_ plugin to implement its own page components, configure the link definition and create new routes to render them in the Security Solution application. 
These new APIs can be called from either `security_solution_serverless` or `security_solution_ess`, allowing those plugins to have their own offering-specific pages.

The new APIs exposed in the security_solution public contract are the following:

 - `extraAppLinks$`: Observable to add extra app_links into the application links configuration, so they are stored and included in the SecuritySolution plugin `deepLinks` registry, to make them accessible from anywhere in the application using the `chrome.navLinks` API.
 
 - `extraRoutes$`: Observable to add extra routes into the main Router, so it can render the new page components. These additional routes are appended after the "sub-plugin" (_alerts_, _timeline_, ...) routes, so it is not possible to override an existing route path.
 
### New `security-solution-navigation` package

Since now we need to use the same navigation components and hooks in different plugins, these functionalities have been extracted to the `@kbn/security-solution-navigation` package, which all Security plugins will depend on (generic, serverless, and ess).

The modules exposed by this package have been extracted from the main security_solution plugin and standardized. They include the Landing pages components (new [storybook](https://ci-artifacts.kibana.dev/storybooks/pr-161667/394abe76676c6a76b2982c1d3f5bb675739c3477/security_solution_packages/index.html?path=/story/landing-links-landing-links-icons-categories--landing-links-icons-categories) available), navigation hooks, and link utilities. Also, some types and constants have been moved to this package.

A new context provider has also been created, which needs to be in place in order to use this package. The `<NavigationProvider core={core}>` is required for the package functionalities to have access to the Kibana core navigation APIs: `navigateToUrl`, `navigateToApp`, and `getUrlForApp`.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: YulNaumenko <jo.naumenko@gmail.com>
This commit is contained in:
Sergi Massaneda 2023-07-25 23:02:10 +02:00 committed by GitHub
parent 14641e668e
commit 3d6dbd4ad7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
156 changed files with 4589 additions and 2026 deletions

View file

@ -1174,6 +1174,7 @@ module.exports = {
overrides: [
{
files: [
'x-pack/packages/security-solution/navigation/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution_ess/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/security_solution_serverless/**/*.{js,mjs,ts,tsx}',

1
.github/CODEOWNERS vendored
View file

@ -586,6 +586,7 @@ x-pack/test/security_api_integration/packages/helpers @elastic/kibana-core
x-pack/plugins/security @elastic/kibana-security
x-pack/plugins/security_solution_ess @elastic/security-solution
x-pack/test/cases_api_integration/common/plugins/security_solution @elastic/response-ops
x-pack/packages/security-solution/navigation @elastic/security-threat-hunting-explore
x-pack/plugins/security_solution @elastic/security-solution
x-pack/plugins/security_solution_serverless @elastic/security-solution
x-pack/packages/security-solution/side_nav @elastic/security-threat-hunting-explore

View file

@ -588,6 +588,7 @@
"@kbn/security-plugin": "link:x-pack/plugins/security",
"@kbn/security-solution-ess": "link:x-pack/plugins/security_solution_ess",
"@kbn/security-solution-fixtures-plugin": "link:x-pack/test/cases_api_integration/common/plugins/security_solution",
"@kbn/security-solution-navigation": "link:x-pack/packages/security-solution/navigation",
"@kbn/security-solution-plugin": "link:x-pack/plugins/security_solution",
"@kbn/security-solution-serverless": "link:x-pack/plugins/security_solution_serverless",
"@kbn/security-solution-side-nav": "link:x-pack/packages/security-solution/side_nav",

View file

@ -1166,6 +1166,8 @@
"@kbn/security-solution-ess/*": ["x-pack/plugins/security_solution_ess/*"],
"@kbn/security-solution-fixtures-plugin": ["x-pack/test/cases_api_integration/common/plugins/security_solution"],
"@kbn/security-solution-fixtures-plugin/*": ["x-pack/test/cases_api_integration/common/plugins/security_solution/*"],
"@kbn/security-solution-navigation": ["x-pack/packages/security-solution/navigation"],
"@kbn/security-solution-navigation/*": ["x-pack/packages/security-solution/navigation/*"],
"@kbn/security-solution-plugin": ["x-pack/plugins/security_solution"],
"@kbn/security-solution-plugin/*": ["x-pack/plugins/security_solution/*"],
"@kbn/security-solution-serverless": ["x-pack/plugins/security_solution_serverless"],

View file

@ -0,0 +1,4 @@
## Security Solution Navigation
This package provides resources to be used for Security Solution navigation

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
export { useGetAppUrl, useNavigateTo, useNavigation } from './src/navigation';
export type { GetAppUrl, NavigateTo } from './src/navigation';
export { NavigationProvider } from './src/context';
export { SecurityPageName, LinkCategoryType } from './src/constants';
export type {
NavigationLink,
LinkCategories,
LinkCategory,
TitleLinkCategory,
SeparatorLinkCategory,
AccordionLinkCategory,
} from './src/types';
export { isAccordionLinkCategory, isSeparatorLinkCategory, isTitleLinkCategory } from './src/types';

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: '../../../..',
roots: ['<rootDir>/x-pack/packages/security-solution/navigation'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/security-solution-navigation",
"owner": "@elastic/security-threat-hunting-explore"
}

View file

@ -4,5 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getProjectNavLinks$ } from './nav_links';
export type { ProjectNavLinks, ProjectNavigationLink } from './types';
export * from './src/landing_links';

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.
*/
export {
useGetLinkUrl,
useGetLinkProps,
withLink,
LinkButton,
LinkAnchor,
isExternalId,
} from './src/links';
export type { GetLinkUrl, GetLinkProps, LinkProps } from './src/links';

View file

@ -0,0 +1,19 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
export const mockGetUrlForApp = jest.fn();
export const mockNavigateToApp = jest.fn();
export const mockNavigateToUrl = jest.fn();
export const mockCoreStart = {
application: {
getUrlForApp: mockGetUrlForApp,
navigateToApp: mockNavigateToApp,
navigateToUrl: mockNavigateToUrl,
},
} as unknown as CoreStart;

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export const mockGetAppUrl = jest.fn();
export const mockNavigateTo = jest.fn();

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/security-solution-navigation",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,19 @@
/*
* 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, { createContext } from 'react';
import type { CoreStart } from '@kbn/core/public';
import { mockCoreStart } from '../../mocks/context';
const navigationContext = createContext<CoreStart | null>(mockCoreStart);
export const NavigationProvider: React.FC = ({ children }) => (
<navigationContext.Provider value={mockCoreStart}>{children}</navigationContext.Provider>
);
export const useNavigationContext = (): CoreStart => {
return mockCoreStart;
};

View file

@ -0,0 +1,20 @@
/*
* 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 { mockGetAppUrl, mockNavigateTo } from '../../mocks/navigation';
export const useGetAppUrl = jest.fn(() => {
return { getAppUrl: mockGetAppUrl };
});
export const useNavigateTo = jest.fn(() => {
return { navigateTo: mockNavigateTo };
});
export const useNavigation = jest.fn(() => {
return { navigateTo: mockGetAppUrl, getAppUrl: mockNavigateTo };
});

View file

@ -0,0 +1,90 @@
/*
* 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.
*/
export const SECURITY_UI_APP_ID = 'securitySolutionUI' as const;
export enum SecurityPageName {
administration = 'administration',
alerts = 'alerts',
blocklist = 'blocklist',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All Cases page names must match `CasesDeepLinkId` in x-pack/plugins/cases/public/common/navigation/deep_links.ts
*/
case = 'cases', // must match `CasesDeepLinkId.cases`
caseConfigure = 'cases_configure', // must match `CasesDeepLinkId.casesConfigure`
caseCreate = 'cases_create', // must match `CasesDeepLinkId.casesCreate`
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All cloud security posture page names must match `CloudSecurityPosturePageId` in x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts
*/
cloudSecurityPostureBenchmarks = 'cloud_security_posture-benchmarks',
cloudSecurityPostureDashboard = 'cloud_security_posture-dashboard',
cloudSecurityPostureFindings = 'cloud_security_posture-findings',
cloudSecurityPostureRules = 'cloud_security_posture-rules',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts
*/
cloudDefendPolicies = 'cloud_defend-policies',
dashboards = 'dashboards',
dataQuality = 'data_quality',
detections = 'detections',
detectionAndResponse = 'detection_response',
endpoints = 'endpoints',
eventFilters = 'event_filters',
exceptions = 'exceptions',
exploreLanding = 'explore',
hostIsolationExceptions = 'host_isolation_exceptions',
hosts = 'hosts',
hostsAnomalies = 'hosts-anomalies',
hostsRisk = 'hosts-risk',
hostsEvents = 'hosts-events',
investigate = 'investigate',
kubernetes = 'kubernetes',
landing = 'get_started',
mlLanding = 'machine_learning-landing', // serverless only
network = 'network',
networkAnomalies = 'network-anomalies',
networkDns = 'network-dns',
networkEvents = 'network-events',
networkHttp = 'network-http',
networkTls = 'network-tls',
noPage = '',
overview = 'overview',
policies = 'policy',
responseActionsHistory = 'response_actions_history',
rules = 'rules',
rulesAdd = 'rules-add',
rulesCreate = 'rules-create',
rulesLanding = 'rules-landing',
sessions = 'sessions',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All threat intelligence page names must match `TIPageId` in x-pack/plugins/threat_intelligence/public/common/navigation/types.ts
*/
threatIntelligenceIndicators = 'threat_intelligence-indicators',
timelines = 'timelines',
timelinesTemplates = 'timelines-templates',
trustedApps = 'trusted_apps',
uncommonProcesses = 'uncommon_processes',
users = 'users',
usersAnomalies = 'users-anomalies',
usersAuthentications = 'users-authentications',
usersEvents = 'users-events',
usersRisk = 'users-risk',
entityAnalytics = 'entity_analytics',
entityAnalyticsManagement = 'entity_analytics-management',
coverageOverview = 'coverage-overview',
}
export enum LinkCategoryType {
title = 'title',
collapsibleTitle = 'collapsibleTitle',
accordion = 'accordion',
separator = 'separator',
}

View file

@ -0,0 +1,22 @@
/*
* 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, { useContext, createContext } from 'react';
import type { CoreStart } from '@kbn/core/public';
const navigationContext = createContext<CoreStart | null>(null);
export const NavigationProvider: React.FC<{ core: CoreStart }> = ({ core, children }) => (
<navigationContext.Provider value={core}>{children}</navigationContext.Provider>
);
export const useNavigationContext = (): CoreStart => {
const services = useContext(navigationContext);
if (!services) {
throw new Error('Kibana services not found in navigation context');
}
return services;
};

View file

@ -0,0 +1,32 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiBetaBadge, useEuiTheme } from '@elastic/eui';
export const BETA = i18n.translate('securitySolutionPackages.beta.label', {
defaultMessage: 'Beta',
});
export const BetaBadge = ({ text, className }: { text?: string; className?: string }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiBetaBadge
label={text ?? BETA}
size="s"
css={css`
margin-left: ${euiTheme.size.s};
color: ${euiTheme.colors.text};
vertical-align: middle;
margin-bottom: ${euiTheme.size.xxs};
`}
className={className}
/>
);
};

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export type { LandingLinksIconsProps } from './landing_links_icons';
export type { LandingLinksIconsCategoriesProps } from './landing_links_icons_categories';
export type { LandingLinksImagesProps } from './landing_links_images';
export {
LandingLinksIcons,
LandingLinksIconsCategories,
LandingLinksImages,
LandingLinksImageCards,
} from './lazy';

View file

@ -0,0 +1,109 @@
/*
* 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 type { CoreStart } from '@kbn/core/public';
import type { NavigationLink } from '../types';
import type { LandingLinksIconsProps } from './landing_links_icons';
import { LandingLinksIcons as LandingLinksIconsComponent } from './landing_links_icons';
import { NavigationProvider } from '../context';
const items: NavigationLink[] = [
{
id: 'link1',
title: 'link #1',
description: 'This is the description of the link #1',
landingIcon: 'addDataApp',
},
{
id: 'link2',
title: 'link #2',
description: 'This is the description of the link #2',
isBeta: true,
landingIcon: 'securityAnalyticsApp',
},
{
id: 'link3',
title: 'link #3',
description: 'This is the description of the link #3',
landingIcon: 'spacesApp',
},
{
id: 'link4',
title: 'link #4',
description: 'This is the description of the link #4',
landingIcon: 'appSearchApp',
},
{
id: 'link5',
title: 'link #5',
description: 'This is the description of the link #5',
landingIcon: 'heartbeatApp',
},
{
id: 'link6',
title: 'link #6',
description: 'This is the description of the link #6',
landingIcon: 'lensApp',
},
{
id: 'link7',
title: 'link #7',
description: 'This is the description of the link #7',
landingIcon: 'timelionApp',
},
{
id: 'link8',
title: 'link #8',
description: 'This is the description of the link #8',
landingIcon: 'managementApp',
},
];
export default {
title: 'Landing Links/Landing Links Icons',
description: 'Renders the links with icons.',
decorators: [
(storyFn: Function) => (
<div
css={{
height: '100%',
width: '100%',
background: '#fff',
}}
>
{storyFn()}
</div>
),
],
};
const mockCore = {
application: {
navigateToApp: () => {},
getUrlForApp: () => '#',
},
} as unknown as CoreStart;
export const LandingLinksIcons = (params: LandingLinksIconsProps) => (
<div style={{ padding: '25px' }}>
<NavigationProvider core={mockCore}>
<LandingLinksIconsComponent {...params} />
</NavigationProvider>
</div>
);
LandingLinksIcons.argTypes = {
items: {
control: 'object',
defaultValue: items,
},
};
LandingLinksIcons.parameters = {
layout: 'fullscreen',
};

View file

@ -0,0 +1,90 @@
/*
* 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 { SecurityPageName } from '../constants';
import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation';
import { LandingLinksIcons } from './landing_links_icons';
import { BETA } from './beta_badge';
jest.mock('../navigation');
mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`);
const mockOnLinkClick = jest.fn();
const DEFAULT_NAV_ITEM = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingIcon: 'myTestIcon',
};
describe('LandingLinksIcons', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render items', () => {
const id = SecurityPageName.administration;
const title = 'test label 2';
const description = 'description 2';
const { queryByText } = render(
<LandingLinksIcons
items={[DEFAULT_NAV_ITEM, { ...DEFAULT_NAV_ITEM, id, title, description }]}
/>
);
expect(queryByText(DEFAULT_NAV_ITEM.title)).toBeInTheDocument();
expect(queryByText(DEFAULT_NAV_ITEM.description)).toBeInTheDocument();
expect(queryByText(title)).toBeInTheDocument();
expect(queryByText(description)).toBeInTheDocument();
});
it('should render beta', () => {
const { queryByText } = render(
<LandingLinksIcons items={[{ ...DEFAULT_NAV_ITEM, isBeta: true }]} />
);
expect(queryByText(DEFAULT_NAV_ITEM.title)).toBeInTheDocument();
expect(queryByText(BETA)).toBeInTheDocument();
});
it('should navigate link', () => {
const id = SecurityPageName.administration;
const title = 'test label 2';
const { getByText } = render(
<LandingLinksIcons items={[{ ...DEFAULT_NAV_ITEM, id, title }]} />
);
getByText(title).click();
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.administration,
absolute: false,
path: '',
});
expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' });
});
it('should call onLinkClick', () => {
const id = SecurityPageName.administration;
const title = 'myTestLabel';
const { getByText } = render(
<LandingLinksIcons
items={[{ ...DEFAULT_NAV_ITEM, id, title }]}
onLinkClick={mockOnLinkClick}
/>
);
getByText(title).click();
expect(mockOnLinkClick).toHaveBeenCalledWith(id);
});
});

View file

@ -0,0 +1,98 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { isExternalId, LinkAnchor, type WrappedLinkProps } from '../links';
import type { NavigationLink } from '../types';
import { BetaBadge } from './beta_badge';
export interface LandingLinksIconsProps {
items: NavigationLink[];
urlState?: string;
onLinkClick?: (id: string) => void;
}
const useStyles = () => {
const { euiTheme } = useEuiTheme();
return {
container: css`
min-width: 22em;
`,
title: css`
margin-top: ${euiTheme.size.m};
margin-bottom: ${euiTheme.size.s};
`,
description: css`
max-width: 22em;
`,
};
};
export const LandingLinksIcons: React.FC<LandingLinksIconsProps> = ({
items,
urlState,
onLinkClick,
}) => {
const styles = useStyles();
return (
<EuiFlexGroup gutterSize="xl" wrap>
{items.map(({ id, title, description, landingIcon, isBeta, betaOptions, skipUrlState }) => {
const linkProps: WrappedLinkProps = {
id,
...(!isExternalId(id) && !skipUrlState && { urlState }),
...(onLinkClick && { onClick: () => onLinkClick(id) }),
};
return (
<EuiFlexItem key={id} data-test-subj="LandingItem" grow={false} css={styles.container}>
<EuiFlexGroup
direction="column"
alignItems="flexStart"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false}>
<LinkAnchor tabIndex={-1} {...linkProps}>
<EuiIcon
aria-hidden="true"
size="xl"
type={landingIcon ?? ''}
role="presentation"
/>
</LinkAnchor>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs" css={styles.title}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<LinkAnchor {...linkProps}>
<h2>{title}</h2>
</LinkAnchor>
</EuiFlexItem>
{isBeta && (
<EuiFlexItem grow={false}>
<BetaBadge text={betaOptions?.text} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem css={styles.description}>
<EuiText size="s" color="text">
{description}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export default LandingLinksIcons;

View file

@ -0,0 +1,129 @@
/*
* 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 type { CoreStart } from '@kbn/core/public';
import type { NavigationLink } from '../types';
import type { LandingLinksIconsCategoriesProps } from './landing_links_icons_categories';
import { LandingLinksIconsCategories as LandingLinksIconsCategoriesComponent } from './landing_links_icons_categories';
import { NavigationProvider } from '../context';
const items: NavigationLink[] = [
{
id: 'link1',
title: 'link #1',
description: 'This is the description of the link #1',
landingIcon: 'addDataApp',
},
{
id: 'link2',
title: 'link #2',
description: 'This is the description of the link #2',
isBeta: true,
landingIcon: 'securityAnalyticsApp',
},
{
id: 'link3',
title: 'link #3',
description: 'This is the description of the link #3',
landingIcon: 'spacesApp',
},
{
id: 'link4',
title: 'link #4',
description: 'This is the description of the link #4',
landingIcon: 'appSearchApp',
},
{
id: 'link5',
title: 'link #5',
description: 'This is the description of the link #5',
landingIcon: 'heartbeatApp',
},
{
id: 'link6',
title: 'link #6',
description: 'This is the description of the link #6',
landingIcon: 'lensApp',
},
{
id: 'link7',
title: 'link #7',
description: 'This is the description of the link #7',
landingIcon: 'timelionApp',
},
{
id: 'link8',
title: 'link #8',
description: 'This is the description of the link #8',
landingIcon: 'managementApp',
},
];
export default {
title: 'Landing Links/Landing Links Icons Categories',
description: 'Renders the links with icons grouped by categories.',
decorators: [
(storyFn: Function) => (
<div
css={{
height: '100%',
width: '100%',
background: '#fff',
}}
>
{storyFn()}
</div>
),
],
};
const mockCore = {
application: {
navigateToApp: () => {},
getUrlForApp: () => '#',
},
} as unknown as CoreStart;
export const LandingLinksIconsCategories = (params: LandingLinksIconsCategoriesProps) => (
<div style={{ padding: '25px' }}>
<NavigationProvider core={mockCore}>
<LandingLinksIconsCategoriesComponent {...params} />
</NavigationProvider>
</div>
);
LandingLinksIconsCategories.argTypes = {
links: {
control: 'object',
defaultValue: items,
},
categories: {
control: 'object',
defaultValue: [
{
type: 'title',
label: 'First category',
linkIds: ['link1', 'link2', 'link3'],
},
{
label: 'Second category',
type: 'title',
linkIds: ['link4'],
},
{
label: 'Third category',
type: 'title',
linkIds: ['link5', 'link6', 'link7', 'link8'],
},
],
},
};
LandingLinksIconsCategories.parameters = {
layout: 'fullscreen',
};

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 React from 'react';
import { render } from '@testing-library/react';
import { SecurityPageName } from '../constants';
import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation';
import type { LinkCategories, NavigationLink } from '../types';
import { LandingLinksIconsCategories } from './landing_links_icons_categories';
import { BETA } from './beta_badge';
jest.mock('../navigation');
mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`);
const mockOnLinkClick = jest.fn();
const RULES_ITEM_LABEL = 'elastic rules!';
const EXCEPTIONS_ITEM_LABEL = 'exceptional!';
const HOSTS_ITEM_LABEL = 'hosts!!';
const CATEGORY_1_LABEL = 'first tests category';
const CATEGORY_2_LABEL = 'second tests category';
const categories: LinkCategories = [
{
label: CATEGORY_1_LABEL,
linkIds: [SecurityPageName.rules],
},
{
label: CATEGORY_2_LABEL,
linkIds: [SecurityPageName.exceptions, SecurityPageName.hosts],
},
];
const links: NavigationLink[] = [
{
id: SecurityPageName.rules,
title: RULES_ITEM_LABEL,
description: 'rules',
landingIcon: 'testIcon1',
},
{
id: SecurityPageName.exceptions,
title: EXCEPTIONS_ITEM_LABEL,
description: 'exceptions',
landingIcon: 'testIcon2',
},
{
id: SecurityPageName.hosts,
title: HOSTS_ITEM_LABEL,
description: 'hosts',
landingIcon: 'testIcon3',
},
];
describe('LandingLinksIconsCategories', () => {
it('should render items', () => {
const { queryByText } = render(<LandingLinksIconsCategories {...{ links, categories }} />);
expect(queryByText(RULES_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(HOSTS_ITEM_LABEL)).toBeInTheDocument();
});
it('should render beta', () => {
const link = { ...links[0], isBeta: true };
const { queryByText } = render(
<LandingLinksIconsCategories {...{ links: [link], categories }} />
);
expect(queryByText(RULES_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(BETA)).toBeInTheDocument();
});
it('should render categories', () => {
const { queryByText } = render(<LandingLinksIconsCategories {...{ links, categories }} />);
expect(queryByText(CATEGORY_1_LABEL)).toBeInTheDocument();
expect(queryByText(CATEGORY_2_LABEL)).toBeInTheDocument();
});
it('should render items in the same order as defined', () => {
const testCategories = [
{ ...categories[0], linkIds: [SecurityPageName.hosts, SecurityPageName.exceptions] },
];
const { queryAllByTestId } = render(
<LandingLinksIconsCategories {...{ links, categories: testCategories }} />
);
const renderedItems = queryAllByTestId('LandingItem');
expect(renderedItems[0]).toHaveTextContent(HOSTS_ITEM_LABEL);
expect(renderedItems[1]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL);
});
it('should not render category items that are not present in links', () => {
const testLinks = [links[0], links[1]]; // no hosts
const { queryByText } = render(
<LandingLinksIconsCategories {...{ links: testLinks, categories }} />
);
expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(RULES_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(HOSTS_ITEM_LABEL)).not.toBeInTheDocument();
});
it('should not render category if all items filtered', () => {
const testLinks = [links[1], links[2]]; // no rules
const { queryByText } = render(
<LandingLinksIconsCategories {...{ links: testLinks, categories }} />
);
expect(queryByText(CATEGORY_2_LABEL)).toBeInTheDocument();
expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(HOSTS_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument();
expect(queryByText(RULES_ITEM_LABEL)).not.toBeInTheDocument();
});
it('should navigate link', () => {
const { getByText } = render(<LandingLinksIconsCategories {...{ links, categories }} />);
getByText(RULES_ITEM_LABEL).click();
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.rules,
absolute: false,
path: '',
});
expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/rules' });
});
it('should call onLinkClick', () => {
const { getByText } = render(
<LandingLinksIconsCategories {...{ links, categories, onLinkClick: mockOnLinkClick }} />
);
getByText(RULES_ITEM_LABEL).click();
expect(mockOnLinkClick).toHaveBeenCalledWith(SecurityPageName.rules);
});
});

View file

@ -0,0 +1,94 @@
/*
* 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, { useMemo } from 'react';
import { css } from '@emotion/react';
import { EuiHorizontalRule, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import type { NavigationLink, LinkCategory } from '../types';
import { LandingLinksIcons } from './landing_links_icons';
import { LinkCategoryType } from '../constants';
export interface LandingLinksIconsCategoriesProps {
links: Readonly<NavigationLink[]>;
categories: Readonly<LinkCategory[]>;
urlState?: string;
onLinkClick?: (id: string) => void;
}
type CategoriesLinks = Array<{ type?: LinkCategoryType; label?: string; links: NavigationLink[] }>;
const useStyles = () => {
const { euiTheme } = useEuiTheme();
return {
horizontalRule: css`
margin-top: ${euiTheme.size.m};
margin-bottom: ${euiTheme.size.l};
`,
};
};
export const LandingLinksIconsCategories: React.FC<LandingLinksIconsCategoriesProps> = React.memo(
function LandingLinksIconsCategories({ links, categories, urlState, onLinkClick }) {
const categoriesLinks = useMemo(() => {
const linksById = Object.fromEntries(links.map((link) => [link.id, link]));
return categories.reduce<CategoriesLinks>((acc, { label, linkIds, type }) => {
const linksItem = linkIds.reduce<NavigationLink[]>((linksAcc, linkId) => {
if (linksById[linkId]) {
linksAcc.push(linksById[linkId]);
}
return linksAcc;
}, []);
if (linksItem.length > 0) {
acc.push({ type, label, links: linksItem });
}
return acc;
}, []);
}, [links, categories]);
return (
<>
{categoriesLinks.map(
({ type = LinkCategoryType.title, label, links: categoryLinks }, index) => (
<div key={`${index}_${label}`}>
<CategoryHeading type={type} label={label} index={index} />
<LandingLinksIcons
items={categoryLinks}
urlState={urlState}
onLinkClick={onLinkClick}
/>
<EuiSpacer size="l" />
</div>
)
)}
</>
);
}
);
const CategoryHeading: React.FC<{ type?: LinkCategoryType; label?: string; index: number }> =
React.memo(function CategoryHeading({ type, label, index }) {
const styles = useStyles();
return (
<>
{index > 0 && <EuiSpacer size="xl" />}
{type === LinkCategoryType.title && (
<>
<EuiTitle size="xxxs">
<h2>{label}</h2>
</EuiTitle>
<EuiHorizontalRule css={styles.horizontalRule} />
</>
)}
{type === LinkCategoryType.separator && index > 0 && (
<EuiHorizontalRule css={styles.horizontalRule} />
)}
</>
);
});
// eslint-disable-next-line import/no-default-export
export default LandingLinksIconsCategories;

View file

@ -0,0 +1,91 @@
/*
* 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 type { CoreStart } from '@kbn/core/public';
import type { NavigationLink } from '../types';
import type { LandingLinksImagesProps } from './landing_links_images';
import { LandingLinksImages as LandingLinksImagesComponent } from './landing_links_images';
import { NavigationProvider } from '../context';
const items: NavigationLink[] = [
{
id: 'link1',
title: 'link #1',
description: 'This is the description of the link #1',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link2',
title: 'link #2',
description: 'This is the description of the link #2',
isBeta: true,
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link3',
title: 'link #3',
description: 'This is the description of the link #3',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link4',
title: 'link #4',
description: 'This is the description of the link #4',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link5',
title: 'link #5',
description: 'This is the description of the link #5',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
];
export default {
title: 'Landing Links/Landing Links Images',
description: 'Renders the links with images in a vertical layout',
decorators: [
(storyFn: Function) => (
<div
css={{
height: '100%',
width: '100%',
background: '#fff',
}}
>
{storyFn()}
</div>
),
],
};
const mockCore = {
application: {
navigateToApp: () => {},
getUrlForApp: () => '#',
},
} as unknown as CoreStart;
export const LandingLinksImages = (params: LandingLinksImagesProps) => (
<div style={{ padding: '25px' }}>
<NavigationProvider core={mockCore}>
<LandingLinksImagesComponent {...params} />
</NavigationProvider>
</div>
);
LandingLinksImages.argTypes = {
items: {
control: 'object',
defaultValue: items,
},
};
LandingLinksImages.parameters = {
layout: 'fullscreen',
};

View file

@ -0,0 +1,92 @@
/*
* 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 { SecurityPageName } from '../constants';
import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation';
import { LandingLinksImages } from './landing_links_images';
import { BETA } from './beta_badge';
jest.mock('../navigation');
mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`);
const mockOnLinkClick = jest.fn();
const DEFAULT_NAV_ITEM = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingImage: 'TEST_IMAGE.png',
};
describe('LandingLinksImages', () => {
it('should render', () => {
const title = 'test label';
const { queryByText } = render(<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, title }]} />);
expect(queryByText(title)).toBeInTheDocument();
});
it('should render landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
);
expect(getByTestId('LandingLinksImage')).toHaveAttribute('src', landingImage);
});
it('should render beta tag when isBeta is true', () => {
const { queryByText } = render(
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, isBeta: true }]} />
);
expect(queryByText(BETA)).toBeInTheDocument();
});
it('should not render beta tag when isBeta is false', () => {
const { queryByText } = render(<LandingLinksImages items={[DEFAULT_NAV_ITEM]} />);
expect(queryByText(BETA)).not.toBeInTheDocument();
});
it('should navigate link', () => {
const id = SecurityPageName.administration;
const title = 'test label 2';
const { getByText } = render(
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, id, title }]} />
);
getByText(title).click();
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.administration,
absolute: false,
path: '',
});
expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' });
});
it('should call onLinkClick', () => {
const id = SecurityPageName.administration;
const title = 'myTestLabel';
const { getByText } = render(
<LandingLinksImages
items={[{ ...DEFAULT_NAV_ITEM, id, title }]}
onLinkClick={mockOnLinkClick}
/>
);
getByText(title).click();
expect(mockOnLinkClick).toHaveBeenCalledWith(id);
});
});

View file

@ -0,0 +1,111 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiPanel,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import { isExternalId, LinkAnchor, type WrappedLinkProps } from '../links';
import { BetaBadge } from './beta_badge';
import type { NavigationLink } from '../types';
export interface LandingLinksImagesProps {
items: NavigationLink[];
urlState?: string;
onLinkClick?: (id: string) => void;
}
const useStyles = () => {
const { euiTheme } = useEuiTheme();
return {
link: css`
color: inherit;
&:hover {
text-decoration: none;
}
`,
image: css`
align-items: center;
`,
content: css`
padding-left: ${euiTheme.size.s};
`,
titleContainer: css`
display: flex;
align-items: center;
`,
title: css`
color: ${euiTheme.colors.primaryText};
align-items: center;
`,
description: css`
padding-top: ${euiTheme.size.xs};
max-width: 550px;
`,
};
};
export const LandingLinksImages: React.FC<LandingLinksImagesProps> = React.memo(
function LandingLinksImages({ items, urlState, onLinkClick }) {
const styles = useStyles();
return (
<EuiFlexGroup direction="column">
{items.map(
({ id, title, description, landingImage, isBeta, betaOptions, skipUrlState }) => {
const linkProps: WrappedLinkProps = {
id,
...(!isExternalId(id) && !skipUrlState && { urlState }),
...(onLinkClick && { onClick: () => onLinkClick(id) }),
};
return (
<EuiFlexItem key={id} data-test-subj="LandingItem">
<LinkAnchor {...linkProps} tabIndex={-1} css={styles.link}>
{/* Empty onClick is to force hover style on `EuiPanel` */}
<EuiPanel hasBorder hasShadow={false} paddingSize="m" onClick={() => {}}>
<EuiFlexGroup>
<EuiFlexItem grow={false} css={styles.image}>
{landingImage && (
<EuiImage
data-test-subj="LandingLinksImage"
size="l"
role="presentation"
alt=""
src={landingImage}
/>
)}
</EuiFlexItem>
<EuiFlexItem css={styles.content}>
<div css={styles.titleContainer}>
<EuiTitle size="s" css={styles.title}>
<h2>{title}</h2>
</EuiTitle>
{isBeta && <BetaBadge text={betaOptions?.text} />}
</div>
<EuiText size="s" color="text" css={styles.description}>
{description}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</LinkAnchor>
</EuiFlexItem>
);
}
)}
</EuiFlexGroup>
);
}
);
// eslint-disable-next-line import/no-default-export
export default LandingLinksImages;

View file

@ -0,0 +1,103 @@
/*
* 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 type { CoreStart } from '@kbn/core/public';
import type { NavigationLink } from '../types';
import type { LandingLinksImagesProps } from './landing_links_images_cards';
import { LandingLinksImageCards as LandingLinksImageCardsComponent } from './landing_links_images_cards';
import { NavigationProvider } from '../context';
const items: NavigationLink[] = [
{
id: 'link1',
title: 'link #1',
description: 'This is the description of the link #1',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link2',
title: 'link #2',
description: 'This is the description of the link #2',
isBeta: true,
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link3',
title: 'link #3',
description: 'This is the description of the link #3',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link4',
title: 'link #4',
description: 'This is the description of the link #4',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link5',
title: 'link #5',
description: 'This is the description of the link #5',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link6',
title: 'link #6',
description: 'This is the description of the link #6',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
{
id: 'link7',
title: 'link #7',
description: 'This is the description of the link #7',
landingImage: 'https://dummyimage.com/360x200/efefef/000',
},
];
export default {
title: 'Landing Links/Landing Links Image Cards',
description: 'Renders the links with images in a horizontal layout',
decorators: [
(storyFn: Function) => (
<div
css={{
height: '100%',
width: '100%',
background: '#fff',
}}
>
{storyFn()}
</div>
),
],
};
const mockCore = {
application: {
navigateToApp: () => {},
getUrlForApp: () => '#',
},
} as unknown as CoreStart;
export const LandingLinksImageCards = (params: LandingLinksImagesProps) => (
<div style={{ padding: '25px' }}>
<NavigationProvider core={mockCore}>
<LandingLinksImageCardsComponent {...params} />
</NavigationProvider>
</div>
);
LandingLinksImageCards.argTypes = {
items: {
control: 'object',
defaultValue: items,
},
};
LandingLinksImageCards.parameters = {
layout: 'fullscreen',
};

View file

@ -0,0 +1,94 @@
/*
* 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 { SecurityPageName } from '../constants';
import { mockNavigateTo, mockGetAppUrl } from '../../mocks/navigation';
import { LandingLinksImageCards } from './landing_links_images_cards';
import { BETA } from './beta_badge';
jest.mock('../navigation');
mockGetAppUrl.mockImplementation(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`);
const mockOnLinkClick = jest.fn();
const DEFAULT_NAV_ITEM = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingImage: 'TEST_IMAGE.png',
};
describe('LandingLinksImageCards', () => {
it('should render', () => {
const title = 'test label';
const { queryByText } = render(
<LandingLinksImageCards items={[{ ...DEFAULT_NAV_ITEM, title }]} />
);
expect(queryByText(title)).toBeInTheDocument();
});
it('should render landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<LandingLinksImageCards items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
);
expect(getByTestId('LandingImageCard-image')).toHaveAttribute('src', landingImage);
});
it('should render beta tag when isBeta is true', () => {
const { queryByText } = render(
<LandingLinksImageCards items={[{ ...DEFAULT_NAV_ITEM, isBeta: true }]} />
);
expect(queryByText(BETA)).toBeInTheDocument();
});
it('should not render beta tag when isBeta is false', () => {
const { queryByText } = render(<LandingLinksImageCards items={[DEFAULT_NAV_ITEM]} />);
expect(queryByText(BETA)).not.toBeInTheDocument();
});
it('should navigate link', () => {
const id = SecurityPageName.administration;
const title = 'test label 2';
const { getByText } = render(
<LandingLinksImageCards items={[{ ...DEFAULT_NAV_ITEM, id, title }]} />
);
getByText(title).click();
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.administration,
absolute: false,
path: '',
});
expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' });
});
it('should call onLinkClick', () => {
const id = SecurityPageName.administration;
const title = 'myTestLabel';
const { getByText } = render(
<LandingLinksImageCards
items={[{ ...DEFAULT_NAV_ITEM, id, title }]}
onLinkClick={mockOnLinkClick}
/>
);
getByText(title).click();
expect(mockOnLinkClick).toHaveBeenCalledWith(id);
});
});

View file

@ -0,0 +1,118 @@
/*
* 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 {
EuiCard,
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiText,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import { isExternalId, withLink, type WrappedLinkProps } from '../links';
import { BetaBadge } from './beta_badge';
import type { NavigationLink } from '../types';
export interface LandingLinksImagesProps {
items: NavigationLink[];
urlState?: string;
onLinkClick?: (id: string) => void;
}
const CARD_WIDTH = 320;
const useStyles = () => {
const { euiTheme } = useEuiTheme();
return {
container: css`
max-width: ${CARD_WIDTH}px;
`,
card: css`
// Needed to use the primary color in the title underlining on hover
.euiCard__title {
color: ${euiTheme.colors.primaryText};
}
`,
titleContainer: css`
display: flex;
align-items: center;
`,
title: css`
color: ${euiTheme.colors.primaryText};
`,
description: css`
padding-top: ${euiTheme.size.xs};
max-width: 550px;
`,
};
};
const EuiCardWithLink = withLink(EuiCard);
export const LandingLinksImageCards: React.FC<LandingLinksImagesProps> = React.memo(
function LandingLinksImageCards({ items, urlState, onLinkClick }) {
const styles = useStyles();
return (
<EuiFlexGroup direction="row" wrap data-test-subj="LandingImageCards">
{items.map(
({ id, landingImage, title, description, isBeta, betaOptions, skipUrlState }) => {
const linkProps: WrappedLinkProps = {
id,
...(!isExternalId(id) && !skipUrlState && { urlState }),
...(onLinkClick && { onClick: () => onLinkClick(id) }),
};
return (
<EuiFlexItem
key={id}
data-test-subj="LandingImageCard-item"
grow={false}
css={styles.container}
>
<EuiCardWithLink
{...linkProps}
hasBorder
textAlign="left"
paddingSize="m"
css={styles.card}
image={
landingImage && (
<EuiImage
data-test-subj="LandingImageCard-image"
role="presentation"
size={CARD_WIDTH}
alt={title}
src={landingImage}
/>
)
}
title={
<div css={styles.titleContainer}>
<EuiTitle size="xs" css={styles.title}>
<h2>{title}</h2>
</EuiTitle>
{isBeta && <BetaBadge text={betaOptions?.text} />}
</div>
}
description={
<EuiText size="s" color="text" css={styles.description}>
{description}
</EuiText>
}
/>
</EuiFlexItem>
);
}
)}
</EuiFlexGroup>
);
}
);
// eslint-disable-next-line import/no-default-export
export default LandingLinksImageCards;

View file

@ -0,0 +1,33 @@
/*
* 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, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
const centerSpinnerStyle = { display: 'flex', margin: 'auto' };
type WithSuspense = <T extends object = {}>(Component: React.ComponentType<T>) => React.FC<T>;
const withSuspense: WithSuspense = (Component) =>
function LazyPageWithSuspense(props) {
return (
<Suspense fallback={<EuiLoadingSpinner size="xl" style={centerSpinnerStyle} />}>
<Component {...props} />
</Suspense>
);
};
const LandingLinksIconsCategoriesLazy = lazy(() => import('./landing_links_icons_categories'));
export const LandingLinksIconsCategories = withSuspense(LandingLinksIconsCategoriesLazy);
const LandingLinksIconsLazy = lazy(() => import('./landing_links_icons'));
export const LandingLinksIcons = withSuspense(LandingLinksIconsLazy);
const LandingLinksImagesLazy = lazy(() => import('./landing_links_images'));
export const LandingLinksImages = withSuspense(LandingLinksImagesLazy);
const LandingLinksImageCardsLazy = lazy(() => import('./landing_links_images_cards'));
export const LandingLinksImageCards = withSuspense(LandingLinksImageCardsLazy);

View file

@ -0,0 +1,198 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { render } from '@testing-library/react';
import {
useGetLinkUrl,
useGetLinkProps,
withLink,
isExternalId,
getAppIdsFromId,
formatPath,
isModified,
} from './links';
import { mockGetAppUrl, mockNavigateTo } from '../mocks/navigation';
jest.mock('./navigation');
const URL = '/the/mocked/url';
mockGetAppUrl.mockReturnValue(URL);
describe('links', () => {
describe('useGetLinkUrl', () => {
it('should return the correct link URL', () => {
const { result } = renderHook(useGetLinkUrl);
const getLinkUrl = result.current;
const linkUrl = getLinkUrl({
id: 'testId',
path: 'testPath',
absolute: false,
urlState: 'testState',
});
expect(linkUrl).toEqual(URL);
// Verify dependencies were called with correct parameters
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: 'testId',
appId: undefined,
path: 'testPath?testState',
absolute: false,
});
});
});
describe('useGetLinkProps', () => {
it('should return the correct link props', () => {
const { result } = renderHook(useGetLinkProps);
const getLinkProps = result.current;
const linkProps = getLinkProps({
id: 'testId',
path: 'testPath',
urlState: 'testState',
onClick: jest.fn(),
});
expect(linkProps).toEqual({
href: URL,
onClick: expect.any(Function),
});
const mockEvent = { preventDefault: jest.fn() } as unknown as React.MouseEvent;
linkProps.onClick(mockEvent);
expect(mockGetAppUrl).toHaveBeenCalledWith({
deepLinkId: 'testId',
appId: undefined,
path: 'testPath?testState',
absolute: false,
});
expect(mockNavigateTo).toHaveBeenCalledWith({ url: URL });
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
});
describe('withLink', () => {
it('should return a wrapped component with link functionality', () => {
const MockComponent = jest.fn(() => <div data-test-subj="mock-component" />);
const WrappedComponent = withLink(MockComponent);
const wrapper = render(<WrappedComponent id="testId" path="testPath" urlState="testState" />);
expect(wrapper.queryByTestId('mock-component')).toBeInTheDocument();
expect(MockComponent).toHaveBeenCalledWith(
expect.objectContaining({ href: URL, onClick: expect.any(Function) }),
{}
);
const mockEvent = { preventDefault: jest.fn() };
// @ts-ignore-next-line
const onClickProp = MockComponent.mock.calls[0][0].onClick;
onClickProp?.(mockEvent);
expect(mockNavigateTo).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
});
describe('isExternalId', () => {
it('should return true for an external id', () => {
const id = 'externalAppId:12345';
const result = isExternalId(id);
expect(result).toBe(true);
});
it('should return false for an internal id', () => {
const id = 'internalId';
const result = isExternalId(id);
expect(result).toBe(false);
});
it('should return true for a root external id', () => {
const id = 'externalAppId:';
const result = isExternalId(id);
expect(result).toBe(true);
});
});
describe('getAppIdsFromId', () => {
it('should return the correct app and deep link ids for an external id', () => {
const id = 'externalAppId:12345';
const result = getAppIdsFromId(id);
expect(result).toEqual({ appId: 'externalAppId', deepLinkId: '12345' });
});
it('should return the correct deep link id for an internal id', () => {
const id = 'internalId';
const result = getAppIdsFromId(id);
expect(result).toEqual({ deepLinkId: 'internalId' });
});
it('should return the correct app id for a root external id', () => {
const id = 'externalAppId:';
const result = getAppIdsFromId(id);
expect(result).toEqual({ appId: 'externalAppId', deepLinkId: '' });
});
});
describe('formatPath', () => {
it('should format the path correctly with URL state', () => {
const path = 'testPath';
const urlState = 'testState';
const result = formatPath(path, urlState);
expect(result).toEqual('testPath?testState');
});
it('should format the path correctly without URL state', () => {
const path = 'testPath';
const urlState = '';
const result = formatPath(path, urlState);
expect(result).toEqual('testPath');
});
it('should format the path correctly with URL state and existing parameters', () => {
const path = 'testPath?existingParam=value';
const urlState = 'testState';
const result = formatPath(path, urlState);
expect(result).toEqual('testPath?existingParam=value&testState');
});
it('should format the path correctly with URL state and parameter path', () => {
const path = 'testPath?parameterPath';
const urlState = 'testState';
const result = formatPath(path, urlState);
expect(result).toEqual('testPath?parameterPath&testState');
});
});
describe('isModified', () => {
it('should return true if event has modifier keys', () => {
const event = {
metaKey: true,
altKey: false,
ctrlKey: false,
shiftKey: true,
} as unknown as React.MouseEvent;
const result = isModified(event);
expect(result).toBe(true);
});
it('should return false if event has no modifier keys', () => {
const event = {
metaKey: false,
altKey: false,
ctrlKey: false,
shiftKey: false,
} as unknown as React.MouseEvent;
const result = isModified(event);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,142 @@
/*
* 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, { type MouseEventHandler, type MouseEvent, useCallback } from 'react';
import { EuiButton, EuiLink, type EuiLinkProps } from '@elastic/eui';
import { useGetAppUrl, useNavigateTo } from './navigation';
export interface WrappedLinkProps {
id: string;
path?: string;
urlState?: string;
}
export interface LinkProps {
onClick: MouseEventHandler;
href: string;
}
export type GetLinkUrl = (
params: WrappedLinkProps & {
absolute?: boolean;
urlState?: string;
}
) => string;
export type GetLinkProps = (
params: WrappedLinkProps & {
/**
* Optional `onClick` callback prop.
* It is composed within the returned `onClick` function to perform extra actions when the link is clicked.
* It does not override the navigation operation.
**/
onClick?: MouseEventHandler;
}
) => LinkProps;
/**
* It returns the `url` to use in link `href`.
*/
export const useGetLinkUrl = () => {
const { getAppUrl } = useGetAppUrl();
const getLinkUrl = useCallback<GetLinkUrl>(
({ id, path = '', absolute = false, urlState }) => {
const formattedPath = urlState ? formatPath(path, urlState) : path;
const { appId, deepLinkId } = getAppIdsFromId(id);
return getAppUrl({ deepLinkId, appId, path: formattedPath, absolute });
},
[getAppUrl]
);
return getLinkUrl;
};
/**
* It returns the `onClick` and `href` props to use in link components based on the` deepLinkId` and `path` parameters.
*/
export const useGetLinkProps = (): GetLinkProps => {
const getLinkUrl = useGetLinkUrl();
const { navigateTo } = useNavigateTo();
const getLinkProps = useCallback<GetLinkProps>(
({ id, path, urlState, onClick: onClickProps }) => {
const url = getLinkUrl({ id, path, urlState });
return {
href: url,
onClick: (ev: MouseEvent) => {
if (isModified(ev)) {
return;
}
if (onClickProps) {
onClickProps(ev);
}
ev.preventDefault();
navigateTo({ url });
},
};
},
[getLinkUrl, navigateTo]
);
return getLinkProps;
};
/**
* HOC that wraps any Link component and makes it a navigation Link.
*/
export const withLink = <T extends Partial<LinkProps>>(
Component: React.ComponentType<T>
): React.FC<Omit<T & WrappedLinkProps, 'href'>> =>
React.memo(function ({ id, path, urlState, onClick: _onClick, ...rest }) {
const getLink = useGetLinkProps();
const { onClick, href } = getLink({ id, path, urlState, onClick: _onClick });
return <Component onClick={onClick} href={href} {...(rest as unknown as T)} />;
});
/**
* Security Solutions internal link button.
*
* `<LinkButton deepLinkId={SecurityPageName.hosts} />;`
*/
export const LinkButton = withLink(EuiButton);
/**
* Security Solutions internal link anchor.
*
* `<LinkAnchor deepLinkId={SecurityPageName.hosts} />;`
*/
export const LinkAnchor = withLink<EuiLinkProps>(EuiLink);
// Utils
export const isExternalId = (id: string): boolean => id.includes(':');
export const getAppIdsFromId = (id: string): { appId?: string; deepLinkId?: string } => {
if (isExternalId(id)) {
const [appId, deepLinkId] = id.split(':');
return { appId, deepLinkId };
}
return { deepLinkId: id }; // undefined `appId` for internal Security Solution links
};
export const formatPath = (path: string, urlState: string) => {
const urlStateClean = urlState.replace('?', '');
const [urlPath, parameterPath] = path.split('?');
let queryParams = '';
if (urlStateClean && parameterPath) {
queryParams = `?${parameterPath}&${urlStateClean}`;
} else if (parameterPath) {
queryParams = `?${parameterPath}`;
} else if (urlStateClean) {
queryParams = `?${urlStateClean}`;
}
return `${urlPath}${queryParams}`;
};
export const isModified = (event: MouseEvent) =>
event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;

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 { useGetAppUrl, useNavigateTo } from './navigation';
import { mockGetUrlForApp, mockNavigateToApp, mockNavigateToUrl } from '../mocks/context';
import { renderHook } from '@testing-library/react-hooks';
jest.mock('./context');
const URL = '/the/mocked/url';
mockGetUrlForApp.mockReturnValue(URL);
describe('yourFile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('useGetAppUrl', () => {
it('returns the correct app URL', () => {
const { result } = renderHook(useGetAppUrl);
const { getAppUrl } = result.current;
const appUrl = getAppUrl({ appId: 'testAppId', path: 'testPath', absolute: false });
expect(appUrl).toEqual(URL);
expect(mockGetUrlForApp).toHaveBeenCalledWith('testAppId', {
path: 'testPath',
absolute: false,
});
});
});
describe('useNavigateTo', () => {
it('navigates to the app URL', () => {
const { result } = renderHook(useNavigateTo);
const { navigateTo } = result.current;
// Call the navigateTo function with test parameters
navigateTo({ appId: 'testAppId', deepLinkId: 'someId', path: 'testPath' });
// Verify dependencies were called with correct parameters
expect(mockNavigateToApp).toHaveBeenCalledWith('testAppId', {
deepLinkId: 'someId',
path: 'testPath',
});
expect(mockNavigateToUrl).not.toHaveBeenCalled();
});
it('navigates to the provided URL', () => {
const { result } = renderHook(useNavigateTo);
const { navigateTo } = result.current;
// Call the navigateTo function with test parameters
navigateTo({ url: URL });
// Verify dependencies were called with correct parameters
expect(mockNavigateToApp).not.toHaveBeenCalled();
expect(mockNavigateToUrl).toHaveBeenCalledWith(URL);
});
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { NavigateToAppOptions } from '@kbn/core/public';
import { useCallback } from 'react';
import { SECURITY_UI_APP_ID } from './constants';
import { useNavigationContext } from './context';
export type GetAppUrl = (param: {
appId?: string;
deepLinkId?: string;
path?: string;
absolute?: boolean;
}) => string;
/**
* The `useGetAppUrl` function returns a full URL to the provided page path by using
* kibana's `getUrlForApp()`
*/
export const useGetAppUrl = () => {
const { getUrlForApp } = useNavigationContext().application;
const getAppUrl = useCallback<GetAppUrl>(
({ appId = SECURITY_UI_APP_ID, ...options }) => getUrlForApp(appId, options),
[getUrlForApp]
);
return { getAppUrl };
};
export type NavigateTo = (
param: {
url?: string;
appId?: string;
} & NavigateToAppOptions
) => void;
/**
* The `navigateTo` function navigates to any app using kibana's `navigateToApp()`.
* When the `{ url: string }` parameter is passed it will navigate using `navigateToUrl()`.
*/
export const useNavigateTo = () => {
const { navigateToApp, navigateToUrl } = useNavigationContext().application;
const navigateTo = useCallback<NavigateTo>(
({ url, appId = SECURITY_UI_APP_ID, ...options }) => {
if (url) {
navigateToUrl(url);
} else {
navigateToApp(appId, options);
}
},
[navigateToApp, navigateToUrl]
);
return { navigateTo };
};
/**
* Returns `navigateTo` and `getAppUrl` navigation hooks
*/
export const useNavigation = () => {
const { navigateTo } = useNavigateTo();
const { getAppUrl } = useGetAppUrl();
return { navigateTo, getAppUrl };
};

View file

@ -0,0 +1,63 @@
/*
* 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 { IconType } from '@elastic/eui';
import { LinkCategoryType } from './constants';
export interface NavigationLink<T extends string = string> {
categories?: LinkCategories<T>;
description?: string;
disabled?: boolean;
id: T;
landingIcon?: IconType;
landingImage?: string;
links?: Array<NavigationLink<T>>;
title: string;
sideNavIcon?: IconType;
skipUrlState?: boolean;
unauthorized?: boolean;
isBeta?: boolean;
betaOptions?: {
text: string;
};
}
export interface LinkCategory<T extends string = string> {
linkIds: readonly T[];
label?: string;
type?: LinkCategoryType;
}
export interface TitleLinkCategory<T extends string = string> extends LinkCategory<T> {
type?: LinkCategoryType.title;
label: string;
}
export interface AccordionLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.accordion;
label: string;
}
export interface SeparatorLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.separator;
}
export type LinkCategories<T extends string = string> = Readonly<Array<LinkCategory<T>>>;
// Type guards
export const isTitleLinkCategory = (category: LinkCategory): category is TitleLinkCategory =>
(category.type == null || category.type === LinkCategoryType.title) && category.label != null;
export const isAccordionLinkCategory = (
category: LinkCategory
): category is AccordionLinkCategory =>
category.type === LinkCategoryType.accordion && category.label != null;
export const isSeparatorLinkCategory = (
category: LinkCategory
): category is SeparatorLinkCategory => category.type === LinkCategoryType.separator;

View file

@ -0,0 +1,20 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react"
]
},
"include": ["**/*.ts", "**/*.tsx"],
"kbn_references": [
"@kbn/i18n",
"@kbn/core",
],
"exclude": ["target/**/*"]
}

View file

@ -17,7 +17,6 @@ Using the `SecurityNav` wrapper provided by `@kbn/shared-ux-page-solution-nav` i
children={
<SolutionSideNavigation
items={items}
footerItems={footerItems}
selectedId={selectedId}
/>
}
@ -32,6 +31,7 @@ Both `items` and `footerItems` props are arrays of `SolutionSideNavItem` type. T
| `label` (required) | `string` | The label that will be displayed for the link |
| `href` (required) | `string` | The path to be used in the link anchor |
| `onClick` | `React.MouseEventHandler` | The handler for the click event |
| `position` | `string` (`top` \| `bottom`) | The position of the link in the main panel, defaults: `top` |
| `items` | `SolutionSideNavItem[]` | The sub-items that will be displayed in the panel |
| `categories` | `Category[]` | The categories to display in the panel for sub-items |
| `iconType` | `IconType` | The EUI IconType to be pre-pended to the label |

View file

@ -6,13 +6,5 @@
*/
export { SolutionSideNav, type SolutionSideNavProps } from './src';
export { LinkCategoryType, SolutionSideNavItemPosition } from './src/types';
export type {
SolutionSideNavItem,
LinkCategory,
TitleLinkCategory,
AccordionLinkCategory,
SeparatorLinkCategory,
LinkCategories,
Tracker,
} from './src/types';
export { SolutionSideNavItemPosition } from './src/types';
export type { SolutionSideNavItem, Tracker } from './src/types';

View file

@ -13,7 +13,6 @@ import {
type SolutionSideNavProps,
type SolutionSideNavItem,
SolutionSideNavItemPosition,
LinkCategoryType,
} from '..';
const items: SolutionSideNavItem[] = [
@ -195,15 +194,15 @@ SolutionSideNav.argTypes = {
control: 'object',
defaultValue: [
{
type: LinkCategoryType.separator,
type: 'separator',
linkIds: ['simpleLink', 'panelLink', 'categoriesPanelLink'],
},
{
type: LinkCategoryType.separator,
type: 'separator',
linkIds: ['linkWrapped'],
},
{
type: LinkCategoryType.separator,
type: 'separator',
linkIds: ['bottomLink', 'bottomLinkPanel', 'bottomLinkSeparator', 'bottomLinkIcon'],
},
],

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { tint, type EuiThemeComputed } from '@elastic/eui';
import { transparentize, type EuiThemeComputed } from '@elastic/eui';
import { css } from '@emotion/css';
export const SolutionSideNavItemStyles = (euiTheme: EuiThemeComputed<{}>) => css`
@ -14,7 +14,7 @@ export const SolutionSideNavItemStyles = (euiTheme: EuiThemeComputed<{}>) => css
font-weight: ${euiTheme.font.weight.regular};
}
&.solutionSideNavItem--isSelected {
background-color: ${tint(euiTheme.colors.lightShade, 0.5)};
background-color: ${transparentize(euiTheme.colors.lightShade, 0.5)};
& * {
font-weight: ${euiTheme.font.weight.medium};
}

View file

@ -22,8 +22,9 @@ import partition from 'lodash/fp/partition';
import classNames from 'classnames';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import type { LinkCategories, SeparatorLinkCategory } from '@kbn/security-solution-navigation';
import { SolutionSideNavPanel } from './solution_side_nav_panel';
import { LinkCategories, SeparatorLinkCategory, SolutionSideNavItemPosition } from './types';
import { SolutionSideNavItemPosition } from './types';
import type { SolutionSideNavItem, Tracker } from './types';
import { TELEMETRY_EVENT } from './telemetry/const';
import { TelemetryContextProvider, useTelemetryContext } from './telemetry/telemetry_context';
@ -199,7 +200,7 @@ const SolutionSideNavItems: React.FC<SolutionSideNavItemsProps> = React.memo(
}
return (
<>
<React.Fragment key={categoryIndex}>
{categoryIndex !== 0 && <EuiSpacer size="s" />}
{categoryItems.map((item) => (
<SolutionSideNavItem
@ -212,7 +213,7 @@ const SolutionSideNavItems: React.FC<SolutionSideNavItemsProps> = React.memo(
/>
))}
<EuiSpacer size="s" />
</>
</React.Fragment>
);
})}
</>

View file

@ -24,7 +24,7 @@ export const SolutionSideNavPanelStyles = (
bottom: 0;
width: ${PANEL_WIDTH};
height: inherit;
z-index: 999;
z-index: 1000;
background-color: ${euiTheme.colors.body};
// If the bottom bar is visible add padding to the navigation

View file

@ -12,7 +12,8 @@ import { BETA_LABEL } from './beta_badge';
import { TELEMETRY_EVENT } from './telemetry/const';
import { METRIC_TYPE } from '@kbn/analytics';
import { TelemetryContextProvider } from './telemetry/telemetry_context';
import { SolutionSideNavItem, LinkCategories, LinkCategoryType } from './types';
import type { SolutionSideNavItem } from './types';
import { type LinkCategories, LinkCategoryType } from '@kbn/security-solution-navigation';
const mockUseIsWithinMinBreakpoint = jest.fn(() => true);
jest.mock('@elastic/eui', () => {

View file

@ -26,12 +26,12 @@ import {
import classNames from 'classnames';
import { METRIC_TYPE } from '@kbn/analytics';
import {
type SolutionSideNavItem,
type LinkCategories,
isAccordionLinkCategory,
isTitleLinkCategory,
isSeparatorLinkCategory,
} from './types';
} from '@kbn/security-solution-navigation';
import type { SolutionSideNavItem } from './types';
import { BetaBadge } from './beta_badge';
import { TELEMETRY_EVENT } from './telemetry/const';
import { useTelemetryContext } from './telemetry/telemetry_context';

View file

@ -8,6 +8,7 @@
import type React from 'react';
import type { UiCounterMetricType } from '@kbn/analytics';
import type { IconType } from '@elastic/eui';
import type { LinkCategories } from '@kbn/security-solution-navigation';
export enum SolutionSideNavItemPosition {
top = 'top',
@ -31,44 +32,6 @@ export interface SolutionSideNavItem<T extends string = string> {
};
}
export enum LinkCategoryType {
title = 'title',
collapsibleTitle = 'collapsibleTitle',
accordion = 'accordion',
separator = 'separator',
}
export interface LinkCategory<T extends string = string> {
linkIds: readonly T[];
label?: string;
type?: LinkCategoryType;
}
export interface TitleLinkCategory<T extends string = string> extends LinkCategory<T> {
type?: LinkCategoryType.title;
label: string;
}
export const isTitleLinkCategory = (category: LinkCategory): category is TitleLinkCategory =>
(category.type == null || category.type === LinkCategoryType.title) && category.label != null;
export interface AccordionLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.accordion;
label: string;
}
export const isAccordionLinkCategory = (
category: LinkCategory
): category is AccordionLinkCategory =>
category.type === LinkCategoryType.accordion && category.label != null;
export interface SeparatorLinkCategory<T extends string = string> extends LinkCategory<T> {
type: LinkCategoryType.separator;
}
export const isSeparatorLinkCategory = (
category: LinkCategory
): category is SeparatorLinkCategory => category.type === LinkCategoryType.separator;
export type LinkCategories<T extends string = string> = Readonly<Array<LinkCategory<T>>>;
export type Tracker = (
type: UiCounterMetricType,
event: string | string[],

View file

@ -17,6 +17,7 @@
"@kbn/i18n",
"@kbn/analytics",
"@kbn/shared-ux-page-solution-nav",
"@kbn/security-solution-navigation",
],
"exclude": ["target/**/*"]
}

View file

@ -9,6 +9,8 @@ import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public';
import * as i18n from './translations';
export { SecurityPageName } from '@kbn/security-solution-navigation';
/**
* as const
*
@ -78,80 +80,6 @@ export const DEFAULT_THREAT_INDEX_KEY = 'securitySolution:defaultThreatIndex' as
export const DEFAULT_THREAT_INDEX_VALUE = ['logs-ti_*'] as const;
export const DEFAULT_THREAT_MATCH_QUERY = '@timestamp >= "now-30d/d"' as const;
export enum SecurityPageName {
administration = 'administration',
alerts = 'alerts',
blocklist = 'blocklist',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All Cases page names must match `CasesDeepLinkId` in x-pack/plugins/cases/public/common/navigation/deep_links.ts
*/
case = 'cases', // must match `CasesDeepLinkId.cases`
caseConfigure = 'cases_configure', // must match `CasesDeepLinkId.casesConfigure`
caseCreate = 'cases_create', // must match `CasesDeepLinkId.casesCreate`
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All cloud security posture page names must match `CloudSecurityPosturePageId` in x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts
*/
cloudSecurityPostureBenchmarks = 'cloud_security_posture-benchmarks',
cloudSecurityPostureDashboard = 'cloud_security_posture-dashboard',
cloudSecurityPostureFindings = 'cloud_security_posture-findings',
cloudSecurityPostureRules = 'cloud_security_posture-rules',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts
*/
cloudDefendPolicies = 'cloud_defend-policies',
dashboards = 'dashboards',
dataQuality = 'data_quality',
detections = 'detections',
detectionAndResponse = 'detection_response',
endpoints = 'endpoints',
eventFilters = 'event_filters',
exceptions = 'exceptions',
exploreLanding = 'explore',
hostIsolationExceptions = 'host_isolation_exceptions',
hosts = 'hosts',
hostsAnomalies = 'hosts-anomalies',
hostsRisk = 'hosts-risk',
hostsEvents = 'hosts-events',
investigate = 'investigate',
kubernetes = 'kubernetes',
landing = 'get_started',
network = 'network',
networkAnomalies = 'network-anomalies',
networkDns = 'network-dns',
networkEvents = 'network-events',
networkHttp = 'network-http',
networkTls = 'network-tls',
noPage = '',
overview = 'overview',
policies = 'policy',
responseActionsHistory = 'response_actions_history',
rules = 'rules',
rulesAdd = 'rules-add',
rulesCreate = 'rules-create',
rulesLanding = 'rules-landing',
sessions = 'sessions',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All threat intelligence page names must match `TIPageId` in x-pack/plugins/threat_intelligence/public/common/navigation/types.ts
*/
threatIntelligenceIndicators = 'threat_intelligence-indicators',
timelines = 'timelines',
timelinesTemplates = 'timelines-templates',
trustedApps = 'trusted_apps',
uncommonProcesses = 'uncommon_processes',
users = 'users',
usersAnomalies = 'users-anomalies',
usersAuthentications = 'users-authentications',
usersEvents = 'users-events',
usersRisk = 'users-risk',
entityAnalytics = 'entity-analytics',
entityAnalyticsManagement = 'entity-analytics-management',
coverageOverview = 'coverage-overview',
}
export const EXPLORE_PATH = '/explore' as const;
export const DASHBOARDS_PATH = '/dashboards' as const;
export const MANAGE_PATH = '/manage' as const;

View file

@ -34,7 +34,7 @@ export const OVERVIEW = '[data-test-subj="solutionSideNavPanelLink-overview"]';
export const DETECTION_RESPONSE = '[data-test-subj="solutionSideNavPanelLink-detection_response"]';
export const ENTITY_ANALYTICS = '[data-test-subj="solutionSideNavPanelLink-entity-analytics"]';
export const ENTITY_ANALYTICS = '[data-test-subj="solutionSideNavPanelLink-entity_analytics"]';
export const KUBERNETES = '[data-test-subj="solutionSideNavPanelLink-kubernetes"]';

View file

@ -19,6 +19,7 @@ import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { CellActionsProvider } from '@kbn/cell-actions';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import { getComments } from '../assistant/get_comments';
import { augmentMessageCodeBlocks, LOCAL_STORAGE_KEY } from '../assistant/helpers';
import { useConversationStore } from '../assistant/use_conversation_store';
@ -57,13 +58,14 @@ const StartAppComponent: FC<StartAppComponent> = ({
store,
theme$,
}) => {
const services = useKibana().services;
const {
i18n,
application: { capabilities },
http,
triggersActionsUi: { actionTypeRegistry },
uiActions,
} = useKibana().services;
} = services;
const { conversations, setConversations } = useConversationStore();
const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } =
@ -108,19 +110,21 @@ const StartAppComponent: FC<StartAppComponent> = ({
<MlCapabilitiesProvider>
<UserPrivilegesProvider kibanaCapabilities={capabilities}>
<ManageUserInfo>
<ReactQueryClientProvider>
<CellActionsProvider
getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}
>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
<NavigationProvider core={services}>
<ReactQueryClientProvider>
<CellActionsProvider
getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}
>
{children}
</PageRouter>
</CellActionsProvider>
</ReactQueryClientProvider>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children}
</PageRouter>
</CellActionsProvider>
</ReactQueryClientProvider>
</NavigationProvider>
</ManageUserInfo>
</UserPrivilegesProvider>
</MlCapabilitiesProvider>

View file

@ -0,0 +1,97 @@
/*
* 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 type { Capabilities } from '@kbn/core/public';
import { RedirectRoute } from './app_routes';
import {
allCasesCapabilities,
noCasesCapabilities,
readCasesCapabilities,
} from '../cases_test_utils';
import { CASES_FEATURE_ID, SERVER_APP_ID } from '../../common/constants';
const mockNotFoundPage = jest.fn(() => null);
jest.mock('./404', () => ({
NotFoundPage: () => mockNotFoundPage(),
}));
const mockRedirect = jest.fn((_: unknown) => null);
jest.mock('react-router-dom', () => ({
Redirect: (params: unknown) => mockRedirect(params),
}));
describe('RedirectRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('RedirectRoute should redirect to overview page when siem and case privileges are all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/get_started' });
});
it('RedirectRoute should redirect to overview page when siem and case privileges are read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/get_started' });
});
it('RedirectRoute should redirect to not_found page when siem and case privileges are off', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).not.toHaveBeenCalled();
expect(mockNotFoundPage).toHaveBeenCalled();
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/get_started' });
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/get_started' });
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/cases' });
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
render(<RedirectRoute capabilities={mockCapabilities} />);
expect(mockRedirect).toHaveBeenCalledWith({ to: '/cases' });
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 type { RouteProps } from 'react-router-dom';
import { Redirect } from 'react-router-dom';
import { EuiLoadingElastic } from '@elastic/eui';
import { Routes, Route } from '@kbn/shared-ux-router';
import type { Capabilities } from '@kbn/core/public';
import useObservable from 'react-use/lib/useObservable';
import { CASES_FEATURE_ID, CASES_PATH, LANDING_PATH, SERVER_APP_ID } from '../../common/constants';
import { NotFoundPage } from './404';
import type { StartServices } from '../types';
export interface AppRoutesProps {
services: StartServices;
subPluginRoutes: RouteProps[];
}
export const AppRoutes: React.FC<AppRoutesProps> = ({ services, subPluginRoutes }) => {
const extraRoutes = useObservable(services.extraRoutes$, null);
return (
<Routes>
{subPluginRoutes.map((route, index) => {
return <Route key={`route-${index}`} {...route} />;
})}
{extraRoutes?.map((route, index) => {
return <Route key={`extra-route-${index}`} {...route} />;
}) ?? (
// `extraRoutes$` have array value (defaults to []), the first render we receive `null` from the useObservable initialization.
// We need to wait until we receive the array value to prevent the fallback redirection to the landing page.
<Route>
<EuiLoadingElastic size="xl" style={{ display: 'flex', margin: 'auto' }} />
</Route>
)}
<Route>
<RedirectRoute capabilities={services.application.capabilities} />
</Route>
</Routes>
);
};
export const RedirectRoute = React.memo<{ capabilities: Capabilities }>(function RedirectRoute({
capabilities,
}) {
if (capabilities[SERVER_APP_ID].show === true) {
return <Redirect to={LANDING_PATH} />;
}
if (capabilities[CASES_FEATURE_ID].read_cases === true) {
return <Redirect to={CASES_PATH} />;
}
return <NotFoundPage />;
});

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { EuiThemeProvider, useEuiTheme } from '@elastic/eui';
import { EuiThemeProvider, useEuiTheme, type EuiThemeComputed } from '@elastic/eui';
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
@ -32,16 +32,16 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<
Omit<KibanaPageTemplateProps, 'ref'> & {
$isShowingTimelineOverlay?: boolean;
$addBottomPadding?: boolean;
theme: EuiThemeComputed; // using computed EUI theme to be consistent with user profile theming
}
>`
.kbnSolutionNav {
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
background-color: ${({ theme }) => theme.colors.emptyShade};
}
.${BOTTOM_BAR_CLASSNAME} {
animation: 'none !important'; // disable the default bottom bar slide animation
background: ${({ theme }) =>
theme.eui.euiColorEmptyShade}; // Override bottom bar black background
background: ${({ theme }) => theme.colors.emptyShade}; // Override bottom bar black background
color: inherit; // Necessary to override the bottom bar 'white text'
transform: ${(
{ $isShowingTimelineOverlay } // Since the bottom bar wraps the whole overlay now, need to override any transforms when it is open
@ -65,7 +65,7 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
// The bottomBar by default has a set 'dark' colorMode that doesn't match the global colorMode from the Advanced Settings
// To keep the mode in sync, we pass in the globalColorMode to the bottom bar here
const { colorMode: globalColorMode } = useEuiTheme();
const { euiTheme, colorMode: globalColorMode } = useEuiTheme();
/*
* StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header
@ -76,6 +76,7 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
return (
<SecuritySolutionFlyoutContextProvider>
<StyledKibanaPageTemplate
theme={euiTheme}
$isShowingTimelineOverlay={isShowingTimelineOverlay}
paddingSize="none"
solutionNav={solutionNavProps}

View file

@ -7,11 +7,9 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Routes, Route } from '@kbn/shared-ux-router';
import { NotFoundPage } from './404';
import { SecurityApp } from './app';
import type { RenderAppProps } from './types';
import { AppRoutes } from './app_routes';
export const renderApp = ({
element,
@ -36,14 +34,7 @@ export const renderApp = ({
theme$={theme$}
>
<ApplicationUsageTrackingProvider>
<Routes>
{subPluginRoutes.map((route, index) => {
return <Route key={`route-${index}`} {...route} />;
})}
<Route>
<NotFoundPage />
</Route>
</Routes>
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
</ApplicationUsageTrackingProvider>
</SecurityApp>,
element

View file

@ -1,89 +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 { render } from '@testing-library/react';
import React from 'react';
import { SecurityPageName } from '../../../app/types';
import type { NavigationLink } from '../../links/types';
import { TestProviders } from '../../mock';
import { LandingLinksIcons } from './landing_links_icons';
import * as telemetry from '../../lib/telemetry';
const DEFAULT_NAV_ITEM: NavigationLink = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingIcon: 'myTestIcon',
};
const spyTrack = jest.spyOn(telemetry, 'track');
const mockNavigateTo = jest.fn();
jest.mock('../../lib/kibana', () => {
const originalModule = jest.requireActual('../../lib/kibana');
return {
...originalModule,
useNavigateTo: () => ({
navigateTo: mockNavigateTo,
}),
};
});
jest.mock('../link_to', () => {
const originalModule = jest.requireActual('../link_to');
return {
...originalModule,
useGetSecuritySolutionUrl: () =>
jest.fn(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`),
};
});
describe('LandingLinksIcons', () => {
it('renders', () => {
const title = 'test label';
const { queryByText } = render(
<TestProviders>
<LandingLinksIcons items={[{ ...DEFAULT_NAV_ITEM, title }]} />
</TestProviders>
);
expect(queryByText(title)).toBeInTheDocument();
});
it('renders navigation link', () => {
const id = SecurityPageName.administration;
const title = 'myTestLable';
const { getByText } = render(
<TestProviders>
<LandingLinksIcons items={[{ ...DEFAULT_NAV_ITEM, id, title }]} />
</TestProviders>
);
getByText(title).click();
expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' });
});
it('sends telemetry', () => {
const id = SecurityPageName.administration;
const title = 'myTestLable';
const { getByText } = render(
<TestProviders>
<LandingLinksIcons items={[{ ...DEFAULT_NAV_ITEM, id, title }]} />
</TestProviders>
);
getByText(title).click();
expect(spyTrack).toHaveBeenCalledWith(
telemetry.METRIC_TYPE.CLICK,
`${telemetry.TELEMETRY_EVENT.LANDING_CARD}${id}`
);
});
});

View file

@ -1,81 +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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { NavItemBetaBadge } from '../navigation/nav_item_beta_badge';
import { SecuritySolutionLinkAnchor, withSecuritySolutionLink } from '../links';
import type { NavigationLink } from '../../links/types';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry';
interface LandingLinksImagesProps {
items: NavigationLink[];
}
const LandingItem = styled(EuiFlexItem)`
min-width: 22em;
`;
const Title = styled(EuiTitle)`
margin-top: ${({ theme }) => theme.eui.euiSizeM};
margin-bottom: ${({ theme }) => theme.eui.euiSizeXS};
`;
const Description = styled(EuiFlexItem)`
max-width: 22em;
`;
const Link = styled.a`
color: inherit;
`;
const SecuritySolutionLink = withSecuritySolutionLink(Link);
export const LandingLinksIcons: React.FC<LandingLinksImagesProps> = ({ items }) => (
<EuiFlexGroup gutterSize="xl" wrap>
{items.map(({ title, description, id, landingIcon, isBeta, betaOptions }) => (
<LandingItem key={id} data-test-subj="LandingItem" grow={false}>
<EuiFlexGroup
direction="column"
alignItems="flexStart"
gutterSize="none"
responsive={false}
>
<EuiFlexItem grow={false}>
<SecuritySolutionLink tabIndex={-1} deepLinkId={id}>
<EuiIcon aria-hidden="true" size="xl" type={landingIcon ?? ''} role="presentation" />
</SecuritySolutionLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Title size="xxs">
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<SecuritySolutionLinkAnchor
deepLinkId={id}
onClick={() => {
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.LANDING_CARD}${id}`);
}}
>
<h2>{title}</h2>
</SecuritySolutionLinkAnchor>
</EuiFlexItem>
{isBeta && (
<EuiFlexItem grow={false}>
<NavItemBetaBadge text={betaOptions?.text} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</Title>
</EuiFlexItem>
<Description>
<EuiText size="s" color="text">
{description}
</EuiText>
</Description>
</EuiFlexGroup>
</LandingItem>
))}
</EuiFlexGroup>
);

View file

@ -1,132 +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 { render } from '@testing-library/react';
import React from 'react';
import { SecurityPageName } from '../../../../common';
import { TestProviders } from '../../mock';
import { LandingLinksIconsCategories } from './landing_links_icons_categories';
import type { NavigationLink } from '../../links';
const RULES_ITEM_LABEL = 'elastic rules!';
const EXCEPTIONS_ITEM_LABEL = 'exceptional!';
const CATEGORY_1_LABEL = 'first tests category';
const CATEGORY_2_LABEL = 'second tests category';
const defaultAppManageLink: NavigationLink = {
id: SecurityPageName.administration,
title: 'admin',
categories: [
{
label: CATEGORY_1_LABEL,
linkIds: [SecurityPageName.rules],
},
{
label: CATEGORY_2_LABEL,
linkIds: [SecurityPageName.exceptions],
},
],
links: [
{
id: SecurityPageName.rules,
title: RULES_ITEM_LABEL,
description: '',
landingIcon: 'testIcon1',
},
{
id: SecurityPageName.exceptions,
title: EXCEPTIONS_ITEM_LABEL,
description: '',
landingIcon: 'testIcon2',
},
],
};
const mockAppManageLink = jest.fn(() => defaultAppManageLink);
jest.mock('../../links/nav_links', () => ({
useRootNavLink: () => mockAppManageLink(),
}));
describe('LandingLinksIconsCategories', () => {
it('should render items', () => {
const { queryByText } = render(
<TestProviders>
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
expect(queryByText(RULES_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument();
});
it('should render items in the same order as defined', () => {
mockAppManageLink.mockReturnValueOnce({
...defaultAppManageLink,
categories: [
{
label: '',
linkIds: [SecurityPageName.exceptions, SecurityPageName.rules],
},
],
});
const { queryAllByTestId } = render(
<TestProviders>
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
const renderedItems = queryAllByTestId('LandingItem');
expect(renderedItems[0]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL);
expect(renderedItems[1]).toHaveTextContent(RULES_ITEM_LABEL);
});
it('should not render category items filtered', () => {
mockAppManageLink.mockReturnValueOnce({
...defaultAppManageLink,
categories: [
{
label: CATEGORY_1_LABEL,
linkIds: [SecurityPageName.rules, SecurityPageName.exceptions],
},
],
links: [
{
id: SecurityPageName.rules,
title: RULES_ITEM_LABEL,
description: '',
landingIcon: 'testIcon1',
},
],
});
const { queryAllByTestId } = render(
<TestProviders>
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
const renderedItems = queryAllByTestId('LandingItem');
expect(renderedItems).toHaveLength(1);
expect(renderedItems[0]).toHaveTextContent(RULES_ITEM_LABEL);
});
it('should not render category if all items filtered', () => {
mockAppManageLink.mockReturnValueOnce({
...defaultAppManageLink,
links: [],
});
const { queryByText } = render(
<TestProviders>
<LandingLinksIconsCategories pageName={defaultAppManageLink.id} />
</TestProviders>
);
expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument();
expect(queryByText(CATEGORY_2_LABEL)).not.toBeInTheDocument();
});
});

View file

@ -1,66 +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 from 'react';
import { css } from '@emotion/react';
import { EuiHorizontalRule, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import type { SecurityPageName } from '../../../../common';
import type { NavigationLink } from '../../links';
import { useRootNavLink } from '../../links/nav_links';
import { LandingLinksIcons } from './landing_links_icons';
type CategoriesLinks = Array<{ label?: string; links: NavigationLink[] }>;
const useCategories = ({ pageName }: { pageName: SecurityPageName }): CategoriesLinks => {
const { links = [], categories = [] } = useRootNavLink(pageName) ?? {};
const linksById = Object.fromEntries(links.map((link) => [link.id, link]));
return categories.reduce<CategoriesLinks>((acc, { label, linkIds }) => {
const linksItem = linkIds.reduce<NavigationLink[]>((linksAcc, linkId) => {
if (linksById[linkId]) {
linksAcc.push(linksById[linkId]);
}
return linksAcc;
}, []);
if (linksItem.length > 0) {
acc.push({ label, links: linksItem });
}
return acc;
}, []);
};
export const LandingLinksIconsCategories = React.memo(function LandingLinksIconsCategories({
pageName,
}: {
pageName: SecurityPageName;
}) {
const { euiTheme } = useEuiTheme();
const categories = useCategories({ pageName });
return (
<>
{categories.map(({ label, links }, index) => (
<div key={`${index}_${label}`}>
{index > 0 && (
<>
<EuiSpacer key="first" size="xl" />
<EuiSpacer key="second" size="xl" />
</>
)}
<EuiTitle size="xxxs">
<h2>{label}</h2>
</EuiTitle>
<EuiHorizontalRule
css={css`
margin-top: ${euiTheme.size.m};
margin-bottom: ${euiTheme.size.l};
`}
/>
<LandingLinksIcons items={links} />
</div>
))}
</>
);
});

View file

@ -1,158 +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 { render } from '@testing-library/react';
import React from 'react';
import { SecurityPageName } from '../../../app/types';
import type { NavigationLink } from '../../links/types';
import { TestProviders } from '../../mock';
import { LandingLinksImages, LandingImageCards } from './landing_links_images';
import * as telemetry from '../../lib/telemetry';
const BETA = 'Beta';
const DEFAULT_NAV_ITEM: NavigationLink = {
id: SecurityPageName.overview,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingImage: 'TEST_IMAGE.png',
};
const BETA_NAV_ITEM: NavigationLink = {
id: SecurityPageName.kubernetes,
title: 'TEST LABEL',
description: 'TEST DESCRIPTION',
landingImage: 'TEST_IMAGE.png',
isBeta: true,
};
jest.mock('../../lib/kibana/kibana_react', () => {
return {
useKibana: jest.fn().mockReturnValue({
services: {
application: {
getUrlForApp: jest.fn(),
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
},
},
}),
};
});
const spyTrack = jest.spyOn(telemetry, 'track');
describe('LandingLinksImages', () => {
it('renders', () => {
const title = 'test label';
const { queryByText } = render(
<TestProviders>
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, title }]} />
</TestProviders>
);
expect(queryByText(title)).toBeInTheDocument();
});
it('renders landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<TestProviders>
<LandingLinksImages items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);
expect(getByTestId('LandingLinksImage')).toHaveAttribute('src', landingImage);
});
it('renders beta tag when isBeta is true', () => {
const { queryByText } = render(
<TestProviders>
<LandingLinksImages items={[BETA_NAV_ITEM]} />
</TestProviders>
);
expect(queryByText(BETA)).toBeInTheDocument();
});
it('does not render beta tag when isBeta is false', () => {
const { queryByText } = render(
<TestProviders>
<LandingLinksImages items={[DEFAULT_NAV_ITEM]} />
</TestProviders>
);
expect(queryByText(BETA)).not.toBeInTheDocument();
});
});
describe('LandingImageCards', () => {
it('renders', () => {
const title = 'test label';
const { queryByText } = render(
<TestProviders>
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, title }]} />
</TestProviders>
);
expect(queryByText(title)).toBeInTheDocument();
});
it('renders landingImage', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST_LABEL';
const { getByTestId } = render(
<TestProviders>
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);
expect(getByTestId('LandingImageCard-image')).toHaveAttribute('src', landingImage);
});
it('sends telemetry', () => {
const landingImage = 'test_image.jpeg';
const title = 'TEST LABEL';
const { getByText } = render(
<TestProviders>
<LandingImageCards items={[{ ...DEFAULT_NAV_ITEM, landingImage, title }]} />
</TestProviders>
);
getByText(title).click();
expect(spyTrack).toHaveBeenCalledWith(
telemetry.METRIC_TYPE.CLICK,
`${telemetry.TELEMETRY_EVENT.LANDING_CARD}${DEFAULT_NAV_ITEM.id}`
);
});
it('renders beta tag when isBeta is true', () => {
const { queryByText } = render(
<TestProviders>
<LandingImageCards items={[BETA_NAV_ITEM]} />
</TestProviders>
);
expect(queryByText(BETA)).toBeInTheDocument();
});
it('does not render beta tag when isBeta is false', () => {
const { queryByText } = render(
<TestProviders>
<LandingImageCards items={[DEFAULT_NAV_ITEM]} />
</TestProviders>
);
expect(queryByText(BETA)).not.toBeInTheDocument();
});
});

View file

@ -1,161 +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 {
EuiCard,
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import React from 'react';
import styled from 'styled-components';
import { withSecuritySolutionLink } from '../links';
import { NavItemBetaBadge } from '../navigation/nav_item_beta_badge';
import type { NavigationLink } from '../../links/types';
import { TELEMETRY_EVENT, track } from '../../lib/telemetry';
interface LandingImagesProps {
items: NavigationLink[];
}
const PrimaryEuiTitle = styled(EuiTitle)`
color: ${(props) => props.theme.eui.euiColorPrimary};
`;
const LandingLinksDescripton = styled(EuiText)`
padding-top: ${({ theme }) => theme.eui.euiSizeXS};
max-width: 550px;
`;
const Link = styled.a`
color: inherit;
`;
const StyledFlexItem = styled(EuiFlexItem)`
align-items: center;
`;
const Content = styled(EuiFlexItem)`
padding-left: ${({ theme }) => theme.eui.euiSizeS};
`;
const FlexTitle = styled.div`
display: flex;
align-items: center;
`;
const TitleText = styled.h2`
display: inline;
`;
const SecuritySolutionLink = withSecuritySolutionLink(Link);
export const LandingLinksImages: React.FC<LandingImagesProps> = ({ items }) => (
<EuiFlexGroup direction="column">
{items.map(({ title, description, landingImage, id, isBeta, betaOptions }) => (
<EuiFlexItem key={id} data-test-subj="LandingItem">
<SecuritySolutionLink
deepLinkId={id}
tabIndex={-1}
onClick={() => {
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.LANDING_CARD}${id}`);
}}
>
{/* Empty onClick is to force hover style on `EuiPanel` */}
<EuiPanel hasBorder hasShadow={false} paddingSize="m" onClick={() => {}}>
<EuiFlexGroup>
<StyledFlexItem grow={false}>
{landingImage && (
<EuiImage
data-test-subj="LandingLinksImage"
size="l"
role="presentation"
alt=""
src={landingImage}
/>
)}
</StyledFlexItem>
<Content>
<PrimaryEuiTitle size="s">
<FlexTitle>
<TitleText>{title}</TitleText>
{isBeta && <NavItemBetaBadge text={betaOptions?.text} />}
</FlexTitle>
</PrimaryEuiTitle>
<LandingLinksDescripton size="s" color="text">
{description}
</LandingLinksDescripton>
</Content>
</EuiFlexGroup>
</EuiPanel>
</SecuritySolutionLink>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
const CARD_WIDTH = 320;
const LandingImageCardItem = styled(EuiFlexItem)`
max-width: ${CARD_WIDTH}px;
`;
const LandingCardDescription = styled.span`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
padding-top: ${({ theme }) => theme.eui.euiSizeXS};
`;
// Needed to use the primary color in the title underlining on hover
const PrimaryTitleCard = styled(EuiCard)`
.euiCard__title {
color: ${(props) => props.theme.eui.euiColorPrimary};
}
`;
const SecuritySolutionCard = withSecuritySolutionLink(PrimaryTitleCard);
export const LandingImageCards: React.FC<LandingImagesProps> = React.memo(({ items }) => (
<EuiFlexGroup direction="row" wrap>
{items.map(({ id, landingImage, title, description, isBeta, betaOptions }) => (
<LandingImageCardItem key={id} data-test-subj="LandingImageCard-item" grow={false}>
<SecuritySolutionCard
deepLinkId={id}
hasBorder
textAlign="left"
paddingSize="m"
image={
landingImage && (
<EuiImage
data-test-subj="LandingImageCard-image"
role="presentation"
size={CARD_WIDTH}
alt={title}
src={landingImage}
/>
)
}
title={
<PrimaryEuiTitle size="xs">
<FlexTitle>
<TitleText>{title}</TitleText>
{isBeta && <NavItemBetaBadge text={betaOptions?.text} />}
</FlexTitle>
</PrimaryEuiTitle>
}
description={<LandingCardDescription>{description}</LandingCardDescription>}
onClick={() => {
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.LANDING_CARD}${id}`);
}}
/>
</LandingImageCardItem>
))}
</EuiFlexGroup>
));
LandingImageCards.displayName = 'LandingImageCards';

View file

@ -7,6 +7,7 @@
import { isEmpty } from 'lodash/fp';
import { useCallback } from 'react';
import { useGetLinkUrl } from '@kbn/security-solution-navigation/links';
import {
useUrlStateQueryParams,
useGetUrlStateQueryParams,
@ -66,17 +67,15 @@ export type GetSecuritySolutionUrl = (param: {
}) => string;
export const useGetSecuritySolutionUrl = () => {
const { getAppUrl } = useAppUrl();
const getLinkUrl = useGetLinkUrl();
const getUrlStateQueryParams = useGetUrlStateQueryParams();
const getSecuritySolutionUrl = useCallback<GetSecuritySolutionUrl>(
({ deepLinkId, path = '', absolute = false, skipSearch = false }) => {
const search = getUrlStateQueryParams(deepLinkId);
const formattedPath = formatPath(path, search, skipSearch);
return getAppUrl({ deepLinkId, path: formattedPath, absolute });
const urlState: string = skipSearch ? '' : getUrlStateQueryParams(deepLinkId);
return getLinkUrl({ id: deepLinkId, path, urlState, absolute });
},
[getAppUrl, getUrlStateQueryParams]
[getLinkUrl, getUrlStateQueryParams]
);
return getSecuritySolutionUrl;

View file

@ -0,0 +1,25 @@
/*
* 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';
export const useSecuritySolutionLinkProps = jest.fn(({ path }: { path: string }) => ({
href: path || '',
onClick: () => {},
}));
export const useGetSecuritySolutionLinkProps = () => useSecuritySolutionLinkProps;
export const withSecuritySolutionLink = <T extends Partial<{ href: string }>>(
WrappedComponent: React.ComponentType<T>
) =>
function WithSecuritySolutionLink({ path, ...rest }: Omit<T, 'href'> & { path: string }) {
return (
<WrappedComponent
{...({ ...useSecuritySolutionLinkProps({ path }), ...rest } as unknown as T)}
/>
);
};

View file

@ -28,27 +28,16 @@ import {
SecuritySolutionLinkButton,
} from '.';
import { SecurityPageName } from '../../../app/types';
import { mockGetAppUrl, mockNavigateTo } from '@kbn/security-solution-navigation/mocks/navigation';
jest.mock('../link_to');
jest.mock('@kbn/security-solution-navigation/src/navigation');
jest.mock('../navigation/use_url_state_query_params');
jest.mock('../../../overview/components/events_by_dataset');
const mockNavigateTo = jest.fn();
jest.mock('../../lib/kibana', () => {
return {
useUiSetting$: jest.fn(),
useKibana: () => ({
services: {
application: {
navigateToApp: jest.fn(),
},
},
}),
useNavigateTo: () => ({
navigateTo: mockNavigateTo,
}),
};
});
jest.mock('../../lib/kibana');
mockGetAppUrl.mockImplementation(({ path }) => path);
describe('Custom Links', () => {
const hostName = 'Host Name';

View file

@ -7,7 +7,7 @@
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiToolTip } from '@elastic/eui';
import type { SyntheticEvent, MouseEventHandler, MouseEvent } from 'react';
import type { SyntheticEvent, MouseEvent } from 'react';
import React, { useMemo, useCallback, useEffect } from 'react';
import { isArray, isNil } from 'lodash/fp';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
@ -22,11 +22,10 @@ import {
getNetworkDetailsUrl,
getCreateCaseUrl,
useFormatUrl,
useGetSecuritySolutionUrl,
} from '../link_to';
import type { FlowTargetSourceDest } from '../../../../common/search_strategy/security_solution/network';
import { FlowTarget } from '../../../../common/search_strategy/security_solution/network';
import { useUiSetting$, useKibana, useNavigateTo } from '../../lib/kibana';
import { useUiSetting$, useKibana } from '../../lib/kibana';
import { isUrlInvalid } from '../../utils/validators';
import * as i18n from './translations';
@ -43,17 +42,18 @@ import {
} from './helpers';
import type { HostsTableType } from '../../../explore/hosts/store/model';
import type { UsersTableType } from '../../../explore/users/store/model';
import { useGetSecuritySolutionLinkProps, withSecuritySolutionLink } from './link_props';
export { useSecuritySolutionLinkProps, type GetSecuritySolutionLinkProps } from './link_props';
export { LinkButton, LinkAnchor } from './helpers';
export { useGetSecuritySolutionLinkProps, withSecuritySolutionLink };
export const DEFAULT_NUMBER_OF_LINK = 5;
/** The default max-height of the Reputation Links popover used to show "+n More" items (e.g. `+9 More`) */
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
const isModified = (event: MouseEvent) =>
event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
// Internal Links
const UserDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
@ -577,74 +577,6 @@ export const WhoIsLink = React.memo<{ children?: React.ReactNode; domain: string
WhoIsLink.displayName = 'WhoIsLink';
interface SecuritySolutionLinkProps {
deepLinkId: SecurityPageName;
path?: string;
}
interface LinkProps {
onClick: MouseEventHandler;
href: string;
}
export type GetSecuritySolutionProps = (
params: SecuritySolutionLinkProps & { onClick?: MouseEventHandler }
) => LinkProps;
/**
* It returns the `onClick` and `href` props to use in link components based on the` deepLinkId` and `path` parameters.
*/
export const useGetSecuritySolutionLinkProps = (): GetSecuritySolutionProps => {
const getSecuritySolutionUrl = useGetSecuritySolutionUrl();
const { navigateTo } = useNavigateTo();
const getSecuritySolutionProps = useCallback<GetSecuritySolutionProps>(
({ deepLinkId, path, onClick: onClickProps }) => {
const url = getSecuritySolutionUrl({ deepLinkId, path });
return {
href: url,
onClick: (ev: MouseEvent) => {
if (isModified(ev)) {
return;
}
ev.preventDefault();
navigateTo({ url });
if (onClickProps) {
onClickProps(ev);
}
},
};
},
[getSecuritySolutionUrl, navigateTo]
);
return getSecuritySolutionProps;
};
/**
* HOC that wraps any Link component and makes it a Security solutions internal navigation Link.
*/
export const withSecuritySolutionLink = <T extends Partial<LinkProps>>(
WrappedComponent: React.FC<T>
) => {
const SecuritySolutionLink: React.FC<Omit<T & SecuritySolutionLinkProps, 'href'>> = ({
deepLinkId,
path,
onClick: onClickProps,
...rest
}) => {
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps();
const { onClick, href } = getSecuritySolutionLinkProps({
deepLinkId,
path,
onClick: onClickProps,
});
return <WrappedComponent onClick={onClick} href={href} {...(rest as unknown as T)} />;
};
return SecuritySolutionLink;
};
/**
* Security Solutions internal link button.
*

View file

@ -0,0 +1,81 @@
/*
* 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, type MouseEventHandler } from 'react';
import { useGetLinkProps, withLink, type LinkProps } from '@kbn/security-solution-navigation/links';
import { useGetUrlStateQueryParams } from '../navigation/use_url_state_query_params';
import type { SecurityPageName } from '../../../../common/constants';
interface SecuritySolutionLinkProps {
deepLinkId: SecurityPageName;
path?: string;
}
type GetSecuritySolutionLinkPropsParams = SecuritySolutionLinkProps & {
/**
* Optional `onClick` callback prop.
* It is composed within the returned `onClick` function to perform extra actions when the link is clicked.
* It does not override the navigation operation.
**/
onClick?: MouseEventHandler;
};
export type GetSecuritySolutionLinkProps = (
params: GetSecuritySolutionLinkPropsParams
) => LinkProps;
/**
* It returns a function to get the `onClick` and `href` props to use in link components
* based on the` deepLinkId` and `path` parameters.
*/
export const useGetSecuritySolutionLinkProps = (): GetSecuritySolutionLinkProps => {
const getLinkProps = useGetLinkProps();
const getUrlStateQueryParams = useGetUrlStateQueryParams();
const getSecuritySolutionLinkProps = useCallback<GetSecuritySolutionLinkProps>(
({ deepLinkId, path, onClick }) => {
const urlState = getUrlStateQueryParams(deepLinkId);
return getLinkProps({ id: deepLinkId, path, urlState, onClick });
},
[getLinkProps, getUrlStateQueryParams]
);
return getSecuritySolutionLinkProps;
};
/**
* It returns the `onClick` and `href` props to use in link components
* based on the` deepLinkId` and `path` parameters.
*/
export const useSecuritySolutionLinkProps: GetSecuritySolutionLinkProps = ({
deepLinkId,
path,
onClick,
}) => {
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps();
const securitySolutionLinkProps = useMemo<LinkProps>(
() => getSecuritySolutionLinkProps({ deepLinkId, path, onClick }),
[getSecuritySolutionLinkProps, deepLinkId, path, onClick]
);
return securitySolutionLinkProps;
};
const withSecuritySolutionLinkProps = <T extends { id: string; urlState?: string }>(
WrappedComponent: React.ComponentType<T>
): React.FC<Omit<T, 'id' | 'urlState'> & { deepLinkId: SecurityPageName }> =>
React.memo(function WithSecuritySolutionProps({ deepLinkId, ...rest }) {
const getUrlStateQueryParams = useGetUrlStateQueryParams();
const urlState = getUrlStateQueryParams(deepLinkId);
return <WrappedComponent {...({ id: deepLinkId, urlState, ...rest } as unknown as T)} />;
});
/**
* HOC that wraps any Link component and makes it a Security solutions internal navigation Link.
*/
export const withSecuritySolutionLink = <T extends Partial<LinkProps>>(
WrappedComponent: React.ComponentType<T>
) => withSecuritySolutionLinkProps(withLink(WrappedComponent));

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { LinkCategoryType, type SeparatorLinkCategory } from '@kbn/security-solution-side-nav';
import { LinkCategoryType, type SeparatorLinkCategory } from '@kbn/security-solution-navigation';
import { SecurityPageName } from '../../../../../common';
export const CATEGORIES: SeparatorLinkCategory[] = [

View file

@ -17,7 +17,7 @@ import { SecurityPageName } from '../../../../app/types';
import type { NavigationLink } from '../../../links';
import { getAncestorLinksInfo } from '../../../links';
import { useRouteSpy } from '../../../utils/route/use_route_spy';
import { useGetSecuritySolutionLinkProps, type GetSecuritySolutionProps } from '../../links';
import { useGetSecuritySolutionLinkProps, type GetSecuritySolutionLinkProps } from '../../links';
import { useNavLinks } from '../../../links/nav_links';
import { useShowTimeline } from '../../../utils/timeline/use_show_timeline';
import { useIsPolicySettingsBarVisible } from '../../../../management/pages/policy/view/policy_hooks';
@ -40,7 +40,7 @@ const isGetStartedNavItem = (id: SecurityPageName) => id === SecurityPageName.la
*/
const formatLink = (
navLink: NavigationLink,
getSecuritySolutionLinkProps: GetSecuritySolutionProps
getSecuritySolutionLinkProps: GetSecuritySolutionLinkProps
): SolutionSideNavItem => ({
id: navLink.id,
label: navLink.title,
@ -70,7 +70,7 @@ const formatLink = (
*/
const formatGetStartedLink = (
navLink: NavigationLink,
getSecuritySolutionLinkProps: GetSecuritySolutionProps
getSecuritySolutionLinkProps: GetSecuritySolutionLinkProps
): SolutionSideNavItem => ({
id: navLink.id,
label: navLink.title,

View file

@ -11,28 +11,18 @@ import { navTabsHostDetails } from '../../../../explore/hosts/pages/details/nav_
import { HostsTableType } from '../../../../explore/hosts/store/model';
import { TabNavigationComponent } from './tab_navigation';
import type { TabNavigationProps } from './types';
import { mockGetUrlForApp } from '@kbn/security-solution-navigation/mocks/context';
jest.mock('@kbn/security-solution-navigation/src/context');
mockGetUrlForApp.mockImplementation(
(appId: string, options?: { path?: string }) => `/app/${appId}${options?.path}`
);
const mockUseRouteSpy = jest.fn();
jest.mock('../../../utils/route/use_route_spy', () => ({
useRouteSpy: () => mockUseRouteSpy(),
}));
jest.mock('../../link_to');
jest.mock('../../../lib/kibana/kibana_react', () => {
const originalModule = jest.requireActual('../../../lib/kibana/kibana_react');
return {
...originalModule,
useKibana: jest.fn().mockReturnValue({
services: {
application: {
getUrlForApp: (appId: string, options?: { path?: string }) =>
`/app/${appId}${options?.path}`,
navigateToApp: jest.fn(),
},
},
}),
useUiSetting$: jest.fn().mockReturnValue([]),
};
});
const SEARCH_QUERY = '?search=test';

View file

@ -20,19 +20,19 @@ export const IconCloudDefend: React.FC<SVGProps<SVGSVGElement>> = ({ ...props })
fillRule="evenodd"
clipRule="evenodd"
d="M4 22.3234L12.7273 26.7447V16.0586L4 11.6373V22.3234ZM2 22.9378C2 23.3147 2.21188 23.6595 2.54807 23.8299L13.2753 29.2644C13.9405 29.6014 14.7273 29.118 14.7273 28.3723V15.4442C14.7273 15.0673 14.5154 14.7225 14.1792 14.5521L3.45192 9.11761C2.78673 8.78062 2 9.26398 2 10.0097V22.9378Z"
fill="black"
fill="#535766"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 10.349L7.99871 6.29552L16 2.24201L24.0013 6.29552L16 10.349ZM15.5481 12.3621C15.8322 12.506 16.1678 12.506 16.4519 12.3621L26.666 7.18758C27.3967 6.81737 27.3967 5.77368 26.666 5.40347L16.4519 0.228946C16.1678 0.0850214 15.8322 0.0850209 15.5481 0.228945L5.33402 5.40347C4.60325 5.77368 4.60325 6.81737 5.33402 7.18758L15.5481 12.3621Z"
fill="black"
fill="#535766"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.2727 16.0586L28 11.6373V17.0039H30V10.0097C30 9.26398 29.2133 8.78062 28.5481 9.11761L17.8208 14.5521C17.4846 14.7225 17.2727 15.0673 17.2727 15.4442V28.3723C17.2727 29.118 18.0595 29.6014 18.7247 29.2644L19.2645 28.9909V17.0039H19.2727V16.0586Z"
fill="black"
fill="#535766"
/>
<path
fillRule="evenodd"

View file

@ -13,10 +13,16 @@ import { i18n } from '@kbn/i18n';
import { camelCase, isArray, isObject } from 'lodash';
import { set } from '@kbn/safer-lodash-set';
import type { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import type { Capabilities, NavigateToAppOptions } from '@kbn/core/public';
import type { Capabilities } from '@kbn/core/public';
import type { CasesPermissions } from '@kbn/cases-plugin/common/ui';
import {
APP_UI_ID,
useGetAppUrl,
useNavigateTo,
useNavigation,
type GetAppUrl,
type NavigateTo,
} from '@kbn/security-solution-navigation';
import {
CASES_FEATURE_ID,
DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_TZ,
@ -182,60 +188,9 @@ export const useGetUserCasesPermissions = () => {
return casesPermissions;
};
export type GetAppUrl = (param: {
appId?: string;
deepLinkId?: string;
path?: string;
absolute?: boolean;
}) => string;
/**
* The `getAppUrl` function returns a full URL to the provided page path by using
* kibana's `getUrlForApp()`
*/
export const useAppUrl = () => {
const { getUrlForApp } = useKibana().services.application;
const getAppUrl = useCallback<GetAppUrl>(
({ appId = APP_UI_ID, ...options }) => getUrlForApp(appId, options),
[getUrlForApp]
);
return { getAppUrl };
};
export type NavigateTo = (
param: {
url?: string;
appId?: string;
} & NavigateToAppOptions
) => void;
/**
* The `navigateTo` function navigates to any app using kibana's `navigateToApp()`.
* When the `{ url: string }` parameter is passed it will navigate using `navigateToUrl()`.
*/
export const useNavigateTo = () => {
const { navigateToApp, navigateToUrl } = useKibana().services.application;
const navigateTo = useCallback<NavigateTo>(
({ url, appId = APP_UI_ID, ...options }) => {
if (url) {
navigateToUrl(url);
} else {
navigateToApp(appId, options);
}
},
[navigateToApp, navigateToUrl]
);
return { navigateTo };
};
/**
* Returns `navigateTo` and `getAppUrl` navigation hooks
*/
export const useNavigation = () => {
const { navigateTo } = useNavigateTo();
const { getAppUrl } = useAppUrl();
return { navigateTo, getAppUrl };
};
export const useAppUrl = useGetAppUrl;
export { useNavigateTo, useNavigation };
export type { GetAppUrl, NavigateTo };
// Get the type for any feature capability
export type FeatureCapability = Capabilities[string];

View file

@ -50,6 +50,7 @@ import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { of } from 'rxjs';
import { UpsellingService } from '../upsellings';
import { NavigationProvider } from '@kbn/security-solution-navigation';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -216,7 +217,11 @@ export const createKibanaContextProviderMock = () => {
const services = createStartServicesMock();
return ({ children }: { children: React.ReactNode }) =>
React.createElement(KibanaContextProvider, { services }, children);
React.createElement(
KibanaContextProvider,
{ services },
React.createElement(NavigationProvider, { core: services }, children)
);
};
export const getMockTheme = (partialTheme: RecursivePartial<EuiTheme>): EuiTheme =>

View file

@ -0,0 +1,61 @@
/*
* 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.
*/
export { METRIC_TYPE } from '@kbn/analytics';
export enum TELEMETRY_EVENT {
// Detections
SIEM_RULE_ENABLED = 'siem_rule_enabled',
SIEM_RULE_DISABLED = 'siem_rule_disabled',
CUSTOM_RULE_ENABLED = 'custom_rule_enabled',
CUSTOM_RULE_DISABLED = 'custom_rule_disabled',
// ML
SIEM_JOB_ENABLED = 'siem_job_enabled',
SIEM_JOB_DISABLED = 'siem_job_disabled',
CUSTOM_JOB_ENABLED = 'custom_job_enabled',
CUSTOM_JOB_DISABLED = 'custom_job_disabled',
JOB_ENABLE_FAILURE = 'job_enable_failure',
JOB_DISABLE_FAILURE = 'job_disable_failure',
// Timeline
TIMELINE_OPENED = 'open_timeline',
TIMELINE_SAVED = 'timeline_saved',
TIMELINE_NAMED = 'timeline_named',
// UI Interactions
TAB_CLICKED = 'tab_',
// Landing pages
LANDING_CARD = 'landing_card_',
// Landing page - dashboard
DASHBOARD = 'navigate_to_dashboard',
CREATE_DASHBOARD = 'create_dashboard',
// Breadcrumbs
BREADCRUMB = 'breadcrumb_',
}
export enum TelemetryEventTypes {
AlertsGroupingChanged = 'Alerts Grouping Changed',
AlertsGroupingToggled = 'Alerts Grouping Toggled',
AlertsGroupingTakeAction = 'Alerts Grouping Take Action',
EntityDetailsClicked = 'Entity Details Clicked',
EntityAlertsClicked = 'Entity Alerts Clicked',
EntityRiskFiltered = 'Entity Risk Filtered',
MLJobUpdate = 'ML Job Update',
CellActionClicked = 'Cell Action Clicked',
AnomaliesCountClicked = 'Anomalies Count Clicked',
}
export enum ML_JOB_TELEMETRY_STATUS {
started = 'started',
startError = 'start_error',
stopped = 'stopped',
stopError = 'stop_error',
moduleInstalled = 'module_installed',
installationError = 'installationError',
}

View file

@ -5,71 +5,15 @@
* 2.0.
*/
import type { UiCounterMetricType } from '@kbn/analytics';
import { METRIC_TYPE } from '@kbn/analytics';
import type { SetupPlugins } from '../../../types';
import type { AlertWorkflowStatus } from '../../types';
export { telemetryMiddleware } from './middleware';
export { METRIC_TYPE };
export * from './constants';
export * from './telemetry_client';
export * from './telemetry_service';
export * from './track';
export * from './types';
type TrackFn = (type: UiCounterMetricType, event: string | string[], count?: number) => void;
const noop = () => {};
let _track: TrackFn;
export const track: TrackFn = (type, event, count) => {
try {
_track(type, event, count);
} catch (error) {
// ignore failed tracking call
}
};
export const initTelemetry = (
{ usageCollection }: Pick<SetupPlugins, 'usageCollection'>,
appId: string
) => {
_track = usageCollection?.reportUiCounter?.bind(null, appId) ?? noop;
};
export enum TELEMETRY_EVENT {
// Detections
SIEM_RULE_ENABLED = 'siem_rule_enabled',
SIEM_RULE_DISABLED = 'siem_rule_disabled',
CUSTOM_RULE_ENABLED = 'custom_rule_enabled',
CUSTOM_RULE_DISABLED = 'custom_rule_disabled',
// ML
SIEM_JOB_ENABLED = 'siem_job_enabled',
SIEM_JOB_DISABLED = 'siem_job_disabled',
CUSTOM_JOB_ENABLED = 'custom_job_enabled',
CUSTOM_JOB_DISABLED = 'custom_job_disabled',
JOB_ENABLE_FAILURE = 'job_enable_failure',
JOB_DISABLE_FAILURE = 'job_disable_failure',
// Timeline
TIMELINE_OPENED = 'open_timeline',
TIMELINE_SAVED = 'timeline_saved',
TIMELINE_NAMED = 'timeline_named',
// UI Interactions
TAB_CLICKED = 'tab_',
// Landing pages
LANDING_CARD = 'landing_card_',
// Landing page - dashboard
DASHBOARD = 'navigate_to_dashboard',
CREATE_DASHBOARD = 'create_dashboard',
// Breadcrumbs
BREADCRUMB = 'breadcrumb_',
}
export const getTelemetryEvent = {
groupedAlertsTakeAction: ({
tableId,

View file

@ -18,7 +18,7 @@ import type {
ReportCellActionClickedParams,
ReportAnomaliesCountClickedParams,
} from './types';
import { TelemetryEventTypes } from './types';
import { TelemetryEventTypes } from './constants';
/**
* Client which aggregate all the available telemetry tracking functions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { TelemetryEvent } from './types';
import { TelemetryEventTypes } from './types';
import { TelemetryEventTypes } from './constants';
const alertsGroupingToggledEvent: TelemetryEvent = {
eventType: TelemetryEventTypes.AlertsGroupingToggled,

View file

@ -8,7 +8,7 @@ import { coreMock } from '@kbn/core/server/mocks';
import { telemetryEvents } from './telemetry_events';
import { TelemetryService } from './telemetry_service';
import { TelemetryEventTypes } from './types';
import { TelemetryEventTypes } from './constants';
describe('TelemetryService', () => {
let service: TelemetryService;

View file

@ -0,0 +1,30 @@
/*
* 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 { UiCounterMetricType } from '@kbn/analytics';
import type { SetupPlugins } from '../../../types';
type TrackFn = (type: UiCounterMetricType, event: string | string[], count?: number) => void;
const noop = () => {};
let _track: TrackFn;
export const track: TrackFn = (type, event, count) => {
try {
_track(type, event, count);
} catch (error) {
// ignore failed tracking call
}
};
export const initTelemetry = (
{ usageCollection }: Pick<SetupPlugins, 'usageCollection'>,
appId: string
) => {
_track = usageCollection?.reportUiCounter?.bind(null, appId) ?? noop;
};

View file

@ -0,0 +1,13 @@
/*
* 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 { track } from './track';
import { METRIC_TYPE, TELEMETRY_EVENT } from './constants';
export const trackLandingLinkClick = (pageId: string) => {
track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.LANDING_CARD}${pageId}`);
};

View file

@ -9,23 +9,12 @@ import type { RootSchema } from '@kbn/analytics-client';
import type { AnalyticsServiceSetup } from '@kbn/core/public';
import type { RiskSeverity } from '../../../../common/search_strategy';
import type { SecurityMetadata } from '../../../actions/types';
import type { ML_JOB_TELEMETRY_STATUS, TelemetryEventTypes } from './constants';
export interface TelemetryServiceSetupParams {
analytics: AnalyticsServiceSetup;
}
export enum TelemetryEventTypes {
AlertsGroupingChanged = 'Alerts Grouping Changed',
AlertsGroupingToggled = 'Alerts Grouping Toggled',
AlertsGroupingTakeAction = 'Alerts Grouping Take Action',
EntityDetailsClicked = 'Entity Details Clicked',
EntityAlertsClicked = 'Entity Alerts Clicked',
EntityRiskFiltered = 'Entity Risk Filtered',
MLJobUpdate = 'ML Job Update',
CellActionClicked = 'Cell Action Clicked',
AnomaliesCountClicked = 'Anomalies Count Clicked',
}
export interface ReportAlertsGroupingChangedParams {
tableId: string;
groupByField: string;
@ -55,15 +44,6 @@ export interface ReportEntityRiskFilteredParams extends EntityParam {
selectedSeverity: RiskSeverity;
}
export enum ML_JOB_TELEMETRY_STATUS {
started = 'started',
startError = 'start_error',
stopped = 'stopped',
stopError = 'stop_error',
moduleInstalled = 'module_installed',
installationError = 'installationError',
}
export interface ReportMLJobUpdateParams {
jobId: string;
isElasticJob: boolean;

View file

@ -6,7 +6,7 @@
*/
import { useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, combineLatest } from 'rxjs';
import type { SecurityPageName } from '../../../common/constants';
import { hasCapabilities } from '../lib/capabilities';
import type {
@ -19,29 +19,52 @@ import type {
} from './types';
/**
* App links updater, it stores the `appLinkItems` recursive hierarchy and keeps
* Main app links updater, it stores the `mainAppLinksUpdater` recursive hierarchy and keeps
* the value of the app links in sync with all application components.
* It can be updated using `updateAppLinks`.
* Read it using subscription or `useAppLinks` hook.
*/
const mainAppLinksUpdater$ = new BehaviorSubject<AppLinkItems>([]);
/**
* Extra App links updater, it stores the `extraAppLinksUpdater`
* that can be added externally to the app links.
* It can be updated using `updatePublicAppLinks`.
*/
const extraAppLinksUpdater$ = new BehaviorSubject<AppLinkItems>([]);
// Combines internal and external appLinks, changes on any of them will trigger a new value
const appLinksUpdater$ = new BehaviorSubject<AppLinkItems>([]);
export const appLinks$ = appLinksUpdater$.asObservable();
// stores a flatten normalized appLinkItems object for internal direct id access
const normalizedAppLinksUpdater$ = new BehaviorSubject<NormalizedLinks>({});
// AppLinks observable
export const appLinks$ = appLinksUpdater$.asObservable();
// Setup the appLinksUpdater$ to combine the internal and external appLinks
combineLatest([mainAppLinksUpdater$, extraAppLinksUpdater$]).subscribe(
([mainAppLinks, extraAppLinks]) => {
appLinksUpdater$.next(Object.freeze([...mainAppLinks, ...extraAppLinks]));
}
);
// Setup the normalizedAppLinksUpdater$ to update the normalized appLinks
appLinks$.subscribe((appLinks) => {
normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(appLinks)));
});
/**
* Updates the app links applying the filter by permissions
* Updates the internal app links applying the filter by permissions
*/
export const updateAppLinks = (
appLinksToUpdate: AppLinkItems,
linksPermissions: LinksPermissions
) => {
const appLinks = processAppLinks(appLinksToUpdate, linksPermissions);
appLinksUpdater$.next(Object.freeze(appLinks));
normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(appLinks)));
};
) => mainAppLinksUpdater$.next(Object.freeze(processAppLinks(appLinksToUpdate, linksPermissions)));
/**
* Updates the app links applying the filter by permissions
*/
export const updateExtraAppLinks = (
appLinksToUpdate: AppLinkItems,
linksPermissions: LinksPermissions
) => extraAppLinksUpdater$.next(Object.freeze(processAppLinks(appLinksToUpdate, linksPermissions)));
/**
* Hook to get the app links updated value
@ -134,11 +157,11 @@ export const getLinksWithHiddenTimeline = (): LinkInfo[] => {
/**
* Creates the `NormalizedLinks` structure from a `LinkItem` array
*/
const getNormalizedLinks = (
function getNormalizedLinks(
currentLinks: AppLinkItems,
parentId?: SecurityPageName
): NormalizedLinks =>
currentLinks.reduce<NormalizedLinks>((normalized, { links, ...currentLink }) => {
): NormalizedLinks {
return currentLinks.reduce<NormalizedLinks>((normalized, { links, ...currentLink }) => {
normalized[currentLink.id] = {
...currentLink,
parentId,
@ -148,6 +171,7 @@ const getNormalizedLinks = (
}
return normalized;
}, {});
}
const getNormalizedLink = (id: SecurityPageName): Readonly<NormalizedLink> | undefined =>
normalizedAppLinksUpdater$.getValue()[id];

View file

@ -9,11 +9,12 @@ import type { Capabilities } from '@kbn/core/types';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types';
import type { IconType } from '@elastic/eui';
import type {
LinkCategory as BaseLinkCategory,
LinkCategories as BaseLinkCategories,
} from '@kbn/security-solution-side-nav';
SecurityPageName,
NavigationLink as GenericNavigationLink,
LinkCategory as GenericLinkCategory,
LinkCategories as GenericLinkCategories,
} from '@kbn/security-solution-navigation';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import type { SecurityPageName } from '../../../common/constants';
import type { UpsellingService } from '../lib/upsellings';
import type { RequiredCapabilities } from '../lib/capabilities';
@ -27,9 +28,6 @@ export interface LinksPermissions {
license?: ILicense;
}
export type LinkCategory = BaseLinkCategory<SecurityPageName>;
export type LinkCategories = BaseLinkCategories<SecurityPageName>;
export interface LinkItem {
/**
* Capabilities strings (using object dot notation) to enable the link.
@ -143,20 +141,6 @@ export type LinkInfo = Omit<LinkItem, 'links'>;
export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName };
export type NormalizedLinks = Partial<Record<SecurityPageName, NormalizedLink>>;
export interface NavigationLink {
categories?: LinkCategories;
description?: string;
disabled?: boolean;
id: SecurityPageName;
landingIcon?: IconType;
landingImage?: string;
links?: NavigationLink[];
title: string;
sideNavIcon?: IconType;
skipUrlState?: boolean;
unauthorized?: boolean;
isBeta?: boolean;
betaOptions?: {
text: string;
};
}
export type NavigationLink = GenericNavigationLink<SecurityPageName>;
export type LinkCategory = GenericLinkCategory<SecurityPageName>;
export type LinkCategories = GenericLinkCategories<SecurityPageName>;

View file

@ -23,6 +23,7 @@ import type {
} from '@testing-library/react-hooks/src/types/react';
import type { UseBaseQueryResult } from '@tanstack/react-query';
import ReactDOM from 'react-dom';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import type { AppLinkItems } from '../../links/types';
import { ExperimentalFeaturesService } from '../../experimental_features_service';
import { applyIntersectionObserverMock } from '../intersection_observer_mock';
@ -240,7 +241,9 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
<KibanaContextProvider services={startServices}>
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
<QueryClientProvider client={queryClient}>
<ConsoleManager>{children}</ConsoleManager>
<NavigationProvider core={startServices}>
<ConsoleManager>{children}</ConsoleManager>
</NavigationProvider>
</QueryClientProvider>
</AppRootProvider>
</KibanaContextProvider>

View file

@ -16,6 +16,7 @@ import type { Store } from 'redux';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import { MockAssistantProvider } from '../mock_assistant_provider';
import { RouteCapture } from '../../components/endpoint/route_capture';
import type { StartPlugins } from '../../../types';
@ -29,35 +30,30 @@ export const AppRootProvider = memo<{
coreStart: CoreStart;
depsStart: Pick<StartPlugins, 'data' | 'fleet'>;
children: ReactNode | ReactNode[];
}>(
({
store,
history,
coreStart: { http, notifications, uiSettings, application },
depsStart: { data },
children,
}) => {
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
const services = useMemo(
() => ({ http, notifications, application, data }),
[application, data, http, notifications]
);
return (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={services}>
<EuiThemeProvider darkMode={isDarkMode}>
<MockAssistantProvider>
}>(({ store, history, coreStart, depsStart: { data }, children }) => {
const { http, notifications, uiSettings, application } = coreStart;
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
const services = useMemo(
() => ({ http, notifications, application, data }),
[application, data, http, notifications]
);
return (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={services}>
<EuiThemeProvider darkMode={isDarkMode}>
<MockAssistantProvider>
<NavigationProvider core={coreStart}>
<Router history={history}>
<RouteCapture>{children}</RouteCapture>
</Router>
</MockAssistantProvider>
</EuiThemeProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
);
}
);
</NavigationProvider>
</MockAssistantProvider>
</EuiThemeProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
);
});
AppRootProvider.displayName = 'AppRootProvider';

View file

@ -16,7 +16,8 @@ import {
useSecurityDashboardsTableColumns,
useSecurityDashboardsTableItems,
} from './use_security_dashboards_table';
import * as telemetry from '../../common/lib/telemetry';
import { METRIC_TYPE, TELEMETRY_EVENT } from '../../common/lib/telemetry/constants';
import * as telemetry from '../../common/lib/telemetry/track';
import { SecurityPageName } from '../../../common/constants';
import * as linkTo from '../../common/components/link_to';
import { getDashboardsByTagIds } from '../../common/containers/dashboards/api';
@ -175,10 +176,7 @@ describe('Security Dashboards Table hooks', () => {
);
result.getByText(mockReturnDashboardTitle).click();
expect(spyTrack).toHaveBeenCalledWith(
telemetry.METRIC_TYPE.CLICK,
telemetry.TELEMETRY_EVENT.DASHBOARD
);
expect(spyTrack).toHaveBeenCalledWith(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD);
});
it('should land on SecuritySolution dashboard view page when dashboard title clicked', async () => {

View file

@ -11,12 +11,13 @@ import { SecurityPageName } from '../../../app/types';
import { TestProviders } from '../../../common/mock';
import { DashboardsLandingPage } from '.';
import { useCapabilities } from '../../../common/lib/kibana';
import * as telemetry from '../../../common/lib/telemetry';
import { DashboardListingTable } from '@kbn/dashboard-plugin/public';
import { METRIC_TYPE, TELEMETRY_EVENT } from '../../../common/lib/telemetry/constants';
import * as telemetry from '../../../common/lib/telemetry/track';
import { MOCK_TAG_NAME } from '../../../common/containers/tags/__mocks__/api';
import { DashboardContextProvider } from '../../context/dashboard_context';
import { act } from 'react-dom/test-utils';
import type { NavigationLink } from '../../../common/links/types';
import { DashboardListingTable } from '@kbn/dashboard-plugin/public';
jest.mock('../../../common/containers/tags/api');
jest.mock('../../../common/lib/kibana');
@ -188,10 +189,7 @@ describe('Dashboards landing', () => {
it('should send telemetry', async () => {
await renderDashboardLanding();
screen.getByTestId('createDashboardButton').click();
expect(spyTrack).toHaveBeenCalledWith(
telemetry.METRIC_TYPE.CLICK,
telemetry.TELEMETRY_EVENT.CREATE_DASHBOARD
);
expect(spyTrack).toHaveBeenCalledWith(METRIC_TYPE.CLICK, TELEMETRY_EVENT.CREATE_DASHBOARD);
});
});
});

View file

@ -16,9 +16,9 @@ import {
import React, { useCallback, useMemo } from 'react';
import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types';
import { DashboardListingTable, LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public';
import { LandingLinksImageCards } from '@kbn/security-solution-navigation/landing_links';
import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { LandingImageCards } from '../../../common/components/landing_links/landing_links_images';
import { SecurityPageName } from '../../../../common/constants';
import { useCapabilities, useNavigateTo } from '../../../common/lib/kibana';
import { useRootNavLink } from '../../../common/links/nav_links';
@ -29,6 +29,8 @@ import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../common/lib/telemet
import { DASHBOARDS_PAGE_TITLE } from '../translations';
import { useCreateSecurityDashboardLink } from '../../hooks/use_create_security_dashboard_link';
import { useGetSecuritySolutionUrl } from '../../../common/components/link_to';
import { useGlobalQueryString } from '../../../common/utils/global_query_string';
import { trackLandingLinkClick } from '../../../common/lib/telemetry/trackers';
import type { TagReference } from '../../context/dashboard_context';
import { useSecurityTags } from '../../context/dashboard_context';
@ -80,7 +82,8 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard
};
export const DashboardsLandingPage = () => {
const dashboardLinks = useRootNavLink(SecurityPageName.dashboards)?.links ?? [];
const { links = [] } = useRootNavLink(SecurityPageName.dashboards) ?? {};
const urlState = useGlobalQueryString();
const { show: canReadDashboard, createNew: canCreateDashboard } =
useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID);
const { navigateTo } = useNavigateTo();
@ -122,7 +125,11 @@ export const DashboardsLandingPage = () => {
<h2>{i18n.DASHBOARDS_PAGE_SECTION_DEFAULT}</h2>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<LandingImageCards items={dashboardLinks} />
<LandingLinksImageCards
items={links}
urlState={urlState}
onLinkClick={trackLandingLinkClick}
/>
<EuiSpacer size="m" />
{canReadDashboard && securityTagsExist && initialFilter ? (

View file

@ -6,24 +6,27 @@
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { LandingLinksImages } from '@kbn/security-solution-navigation/landing_links';
import { SecurityPageName } from '../app/types';
import { HeaderPage } from '../common/components/header_page';
import { useRootNavLink } from '../common/links/nav_links';
import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper';
import { SpyRoute } from '../common/utils/route/spy_routes';
import { LandingLinksImages } from '../common/components/landing_links/landing_links_images';
import { trackLandingLinkClick } from '../common/lib/telemetry/trackers';
import { useGlobalQueryString } from '../common/utils/global_query_string';
const EXPLORE_PAGE_TITLE = i18n.translate('xpack.securitySolution.explore.landing.pageTitle', {
defaultMessage: 'Explore',
});
export const ExploreLandingPage = () => {
const exploreLinks = useRootNavLink(SecurityPageName.exploreLanding)?.links ?? [];
const { links = [] } = useRootNavLink(SecurityPageName.exploreLanding) ?? {};
const urlState = useGlobalQueryString();
return (
<SecuritySolutionPageWrapper>
<HeaderPage title={EXPLORE_PAGE_TITLE} />
<LandingLinksImages items={exploreLinks} />
<LandingLinksImages items={links} urlState={urlState} onLinkClick={trackLandingLinkClick} />
<SpyRoute pageName={SecurityPageName.exploreLanding} />
</SecuritySolutionPageWrapper>
);

View file

@ -24,7 +24,7 @@ jest.mock('@elastic/eui', () => {
};
});
jest.mock('../../../../common/components/link_to');
jest.mock('../../../../common/components/links/link_props');
describe('Port', () => {
const mount = useMountAppended();

View file

@ -14,7 +14,6 @@ import {
parseRoute,
isSubPluginAvailable,
getSubPluginRoutesByCapabilities,
RedirectRoute,
getField,
} from './helpers';
import type { StartedSubPlugins } from './types';
@ -210,92 +209,6 @@ describe('#isSubPluginAvailable', () => {
});
});
describe('RedirectRoute', () => {
it('RedirectRoute should redirect to overview page when siem and case privileges are all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: true },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
`);
});
it('RedirectRoute should redirect to overview page when siem and case privileges are read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
`);
});
it('RedirectRoute should redirect to overview page when siem and case privileges are off', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: noCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
`);
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
`);
});
it('RedirectRoute should redirect to overview page when siem privilege is read and case privilege is read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: true, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/get_started"
/>
`);
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is read', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: readCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/cases"
/>
`);
});
it('RedirectRoute should redirect to cases page when siem privilege is none and case privilege is all', () => {
const mockCapabilities = {
[SERVER_APP_ID]: { show: false, crud: false },
[CASES_FEATURE_ID]: allCasesCapabilities(),
} as unknown as Capabilities;
expect(shallow(<RedirectRoute capabilities={mockCapabilities} />)).toMatchInlineSnapshot(`
<Redirect
to="/cases"
/>
`);
});
});
describe('public helpers getField', () => {
it('should return the same value for signal.rule fields as for kibana.alert.rule fields', () => {
const signalRuleName = getField(mockEcsDataWithAlert, 'signal.rule.name');

View file

@ -9,7 +9,7 @@ import { ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, ALERT_RULE_UUID } from '@kbn/ru
import { get, has, isEmpty } from 'lodash/fp';
import React from 'react';
import type { RouteProps } from 'react-router-dom';
import { matchPath, Redirect } from 'react-router-dom';
import { matchPath } from 'react-router-dom';
import type { Capabilities, CoreStart } from '@kbn/core/public';
import type { DocLinks } from '@kbn/doc-links';
@ -21,7 +21,6 @@ import {
CASES_FEATURE_ID,
CASES_PATH,
EXCEPTIONS_PATH,
LANDING_PATH,
RULES_PATH,
SERVER_APP_ID,
THREAT_INTELLIGENCE_PATH,
@ -198,15 +197,12 @@ export const getSubPluginRoutesByCapabilities = (
capabilities: Capabilities,
services: StartServices
): RouteProps[] => {
return [
...Object.entries(subPlugins).reduce<RouteProps[]>((acc, [key, value]) => {
if (isSubPluginAvailable(key, capabilities)) {
return [...acc, ...value.routes];
}
return Object.entries(subPlugins).reduce<RouteProps[]>((acc, [key, value]) => {
if (isSubPluginAvailable(key, capabilities)) {
acc.push(...value.routes);
} else {
const docLinkSelector = (docLinks: DocLinks) => docLinks.siem.privileges;
return [
...acc,
acc.push(
...value.routes.map((route: RouteProps) => ({
path: route.path,
component: () => {
@ -216,14 +212,11 @@ export const getSubPluginRoutesByCapabilities = (
}
return <NoPrivilegesPage pageName={key} docLinkSelector={docLinkSelector} />;
},
})),
];
}, []),
{
path: '',
component: () => <RedirectRoute capabilities={capabilities} />,
},
];
}))
);
}
return acc;
}, []);
};
export const isSubPluginAvailable = (pluginKey: string, capabilities: Capabilities): boolean => {
@ -233,19 +226,6 @@ export const isSubPluginAvailable = (pluginKey: string, capabilities: Capabiliti
return capabilities[SERVER_APP_ID].show === true;
};
export const RedirectRoute = React.memo<{ capabilities: Capabilities }>(({ capabilities }) => {
const overviewAvailable = isSubPluginAvailable('overview', capabilities);
const casesAvailable = isSubPluginAvailable(CASES_SUB_PLUGIN_KEY, capabilities);
if (overviewAvailable) {
return <Redirect to={LANDING_PATH} />;
}
if (casesAvailable) {
return <Redirect to={CASES_PATH} />;
}
return <Redirect to={LANDING_PATH} />;
});
RedirectRoute.displayName = 'RedirectRoute';
const siemSignalsFieldMappings: Record<string, string> = {
[ALERT_RULE_UUID]: 'signal.rule.id',
[ALERT_RULE_NAME]: 'signal.rule.name',

View file

@ -9,7 +9,7 @@ import type { PluginInitializerContext } from '@kbn/core/public';
import { Plugin } from './plugin';
import type { PluginSetup, PluginStart } from './types';
export type { TimelineModel } from './timelines/store/timeline/model';
export type { NavigationLink } from './common/links';
export type { LinkItem } from './common/links';
export type {
UpsellingService,

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { firstValueFrom } from 'rxjs';
import type { CoreStart, HttpSetup } from '@kbn/core/public';
import type { Store } from 'redux';
import { applyMiddleware, createStore } from 'redux';
@ -52,13 +51,16 @@ jest.mock('../../../services/policies/ingest', () => ({
}));
jest.mock('../../../../common/lib/kibana');
jest.mock('rxjs');
const mockFirstValueFrom = jest.fn();
jest.mock('rxjs', () => ({
...jest.requireActual('rxjs'),
firstValueFrom: () => mockFirstValueFrom(),
}));
type EndpointListStore = Store<Immutable<EndpointState>, Immutable<AppAction>>;
describe('endpoint list middleware', () => {
const getKibanaServicesMock = KibanaServices.get as jest.Mock;
const firstValueFromMock = firstValueFrom as jest.Mock;
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
@ -119,7 +121,7 @@ describe('endpoint list middleware', () => {
});
it('handles `appRequestedEndpointList`', async () => {
firstValueFromMock.mockResolvedValue({ indexFields: [] });
mockFirstValueFrom.mockResolvedValue({ indexFields: [] });
endpointPageHttpMock(fakeHttpServices);
const apiResponse = getEndpointListApiResponse();
fakeHttpServices.get.mockResolvedValue(apiResponse);

View file

@ -6,21 +6,34 @@
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { LandingLinksIconsCategories } from '@kbn/security-solution-navigation/landing_links';
import { SecurityPageName } from '../../app/types';
import { HeaderPage } from '../../common/components/header_page';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { LandingLinksIconsCategories } from '../../common/components/landing_links/landing_links_icons_categories';
import { useRootNavLink } from '../../common/links/nav_links';
import { useGlobalQueryString } from '../../common/utils/global_query_string';
import { trackLandingLinkClick } from '../../common/lib/telemetry/trackers';
const PAGE_TITLE = i18n.translate('xpack.securitySolution.management.landing.settingsTitle', {
defaultMessage: 'Settings',
});
export const ManageLandingPage = () => (
<SecuritySolutionPageWrapper>
<HeaderPage title={PAGE_TITLE} />
<LandingLinksIconsCategories pageName={SecurityPageName.administration} />
<SpyRoute pageName={SecurityPageName.administration} />
</SecuritySolutionPageWrapper>
);
export const ManageLandingPage = () => {
const { links = [], categories = [] } = useRootNavLink(SecurityPageName.administration) ?? {};
const urlState = useGlobalQueryString();
return (
<SecuritySolutionPageWrapper>
<HeaderPage title={PAGE_TITLE} />
<LandingLinksIconsCategories
links={links}
categories={categories}
onLinkClick={trackLandingLinkClick}
urlState={urlState}
/>
<SpyRoute pageName={SecurityPageName.administration} />
</SecuritySolutionPageWrapper>
);
};

View file

@ -9,6 +9,7 @@ import type { PropsWithChildren } from 'react';
import React, { memo } from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import type { Store } from 'redux';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import { UserPrivilegesProvider } from '../../../../../../../common/components/user_privileges/user_privileges_context';
import type { SecuritySolutionQueryClient } from '../../../../../../../common/containers/query_client/query_client_provider';
import { ReactQueryClientProvider } from '../../../../../../../common/containers/query_client/query_client_provider';
@ -25,15 +26,18 @@ export type RenderContextProvidersProps = PropsWithChildren<{
export const RenderContextProviders = memo<RenderContextProvidersProps>(
({ store, depsStart, queryClient, children }) => {
const services = useKibana().services;
const {
application: { capabilities },
} = useKibana().services;
} = services;
return (
<ReduxStoreProvider store={store}>
<ReactQueryClientProvider queryClient={queryClient}>
<SecuritySolutionStartDependenciesContext.Provider value={depsStart}>
<UserPrivilegesProvider kibanaCapabilities={capabilities}>
<CurrentLicense>{children}</CurrentLicense>
<NavigationProvider core={services}>
<CurrentLicense>{children}</CurrentLicense>
</NavigationProvider>
</UserPrivilegesProvider>
</SecuritySolutionStartDependenciesContext.Provider>
</ReactQueryClientProvider>

View file

@ -8,18 +8,23 @@
import { BehaviorSubject } from 'rxjs';
import type { BreadcrumbsNav } from './common/breadcrumbs';
import type { NavigationLink } from './common/links/types';
import { UpsellingService } from './common/lib/upsellings';
import type { PluginStart, PluginSetup } from './types';
const setupMock = () => ({
const setupMock = (): PluginSetup => ({
resolver: jest.fn(),
upselling: new UpsellingService(),
});
const startMock = () => ({
const startMock = (): PluginStart => ({
getNavLinks$: jest.fn(() => new BehaviorSubject<NavigationLink[]>([])),
setIsSidebarEnabled: jest.fn(),
setGetStartedPage: jest.fn(),
getBreadcrumbsNav$: jest.fn(
() => new BehaviorSubject<BreadcrumbsNav>({ leading: [], trailing: [] })
),
setExtraAppLinks: jest.fn(),
setExtraRoutes: jest.fn(),
});
export const securitySolutionMock = {

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { Subject } from 'rxjs';
import { combineLatest, Subject } from 'rxjs';
import type * as H from 'history';
import type {
AppMountParameters,
@ -36,7 +36,7 @@ import { SOLUTION_NAME } from './common/translations';
import { APP_ID, APP_UI_ID, APP_PATH, APP_ICON_SOLUTION } from '../common/constants';
import { updateAppLinks, type LinksPermissions } from './common/links';
import { updateAppLinks, updateExtraAppLinks, type LinksPermissions } from './common/links';
import { registerDeepLinksUpdater } from './common/links/deep_links';
import { licenseService } from './common/hooks/use_license';
import type { SecuritySolutionUiConfigType } from './common/types';
@ -503,21 +503,22 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
async registerAppLinks(core: CoreStart, plugins: StartPlugins) {
const { links, getFilteredLinks } = await this.lazyApplicationLinks();
const { license$ } = plugins.licensing;
const upselling = this.contract.upsellingService;
const { upsellingService, extraAppLinks$ } = this.contract;
registerDeepLinksUpdater(this.appUpdater$);
const baseLinksPermissions: LinksPermissions = {
experimentalFeatures: this.experimentalFeatures,
upselling: upsellingService,
capabilities: core.application.capabilities,
};
license$.subscribe(async (license) => {
const linksPermissions: LinksPermissions = {
experimentalFeatures: this.experimentalFeatures,
upselling,
capabilities: core.application.capabilities,
...baseLinksPermissions,
...(license.type != null && { license }),
};
if (license.type !== undefined) {
linksPermissions.license = license;
}
// set initial links to not block rendering
updateAppLinks(links, linksPermissions);
@ -525,5 +526,12 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
const filteredLinks = await getFilteredLinks(core, plugins);
updateAppLinks(filteredLinks, linksPermissions);
});
combineLatest([extraAppLinks$, license$]).subscribe(([extraAppLinks, license]) => {
updateExtraAppLinks(extraAppLinks, {
...baseLinksPermissions,
...(license.type != null && { license }),
});
});
}
}

View file

@ -6,8 +6,10 @@
*/
import { BehaviorSubject } from 'rxjs';
import type { RouteProps } from 'react-router-dom';
import { UpsellingService } from './common/lib/upsellings';
import type { ContractStartServices, PluginSetup, PluginStart } from './types';
import type { AppLinkItems } from './common/links';
import { navLinks$ } from './common/links/nav_links';
import { breadcrumbsNav$ } from './common/breadcrumbs';
@ -15,8 +17,12 @@ export class PluginContract {
public isSidebarEnabled$: BehaviorSubject<boolean>;
public getStartedComponent$: BehaviorSubject<React.ComponentType | null>;
public upsellingService: UpsellingService;
public extraAppLinks$: BehaviorSubject<AppLinkItems>;
public extraRoutes$: BehaviorSubject<RouteProps[]>;
constructor() {
this.extraAppLinks$ = new BehaviorSubject<AppLinkItems>([]);
this.extraRoutes$ = new BehaviorSubject<RouteProps[]>([]);
this.isSidebarEnabled$ = new BehaviorSubject<boolean>(true);
this.getStartedComponent$ = new BehaviorSubject<React.ComponentType | null>(null);
this.upsellingService = new UpsellingService();
@ -24,6 +30,7 @@ export class PluginContract {
public getStartServices(): ContractStartServices {
return {
extraRoutes$: this.extraRoutes$.asObservable(),
isSidebarEnabled$: this.isSidebarEnabled$.asObservable(),
getStartedComponent$: this.getStartedComponent$.asObservable(),
upselling: this.upsellingService,
@ -40,6 +47,8 @@ export class PluginContract {
public getStartContract(): PluginStart {
return {
getNavLinks$: () => navLinks$,
setExtraAppLinks: (extraAppLinks) => this.extraAppLinks$.next(extraAppLinks),
setExtraRoutes: (extraRoutes) => this.extraRoutes$.next(extraRoutes),
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),
setGetStartedPage: (getStartedComponent) => {

View file

@ -8,13 +8,16 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { LandingLinksIconsCategories } from '@kbn/security-solution-navigation/landing_links';
import { SecurityPageName } from '../../common';
import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper';
import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper';
import { SpyRoute } from '../common/utils/route/spy_routes';
import { LandingLinksIconsCategories } from '../common/components/landing_links/landing_links_icons_categories';
import { Title } from '../common/components/header_page/title';
import { SecuritySolutionLinkButton } from '../common/components/links';
import { useRootNavLink } from '../common/links/nav_links';
import { useGlobalQueryString } from '../common/utils/global_query_string';
import { trackLandingLinkClick } from '../common/lib/telemetry/trackers';
const RULES_PAGE_TITLE = i18n.translate('xpack.securitySolution.rules.landing.pageTitle', {
defaultMessage: 'Rules',
@ -37,15 +40,25 @@ const RulesLandingHeader: React.FC = () => (
</EuiFlexGroup>
);
export const RulesLandingPage = () => (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.rulesLanding}>
<SecuritySolutionPageWrapper>
<RulesLandingHeader />
<EuiSpacer size="xl" />
<LandingLinksIconsCategories pageName={SecurityPageName.rulesLanding} />
<SpyRoute pageName={SecurityPageName.rulesLanding} />
</SecuritySolutionPageWrapper>
</TrackApplicationView>
</PluginTemplateWrapper>
);
export const RulesLandingPage = () => {
const { links = [], categories = [] } = useRootNavLink(SecurityPageName.rulesLanding) ?? {};
const urlState = useGlobalQueryString();
return (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.rulesLanding}>
<SecuritySolutionPageWrapper>
<RulesLandingHeader />
<EuiSpacer size="xl" />
<LandingLinksIconsCategories
links={links}
categories={categories}
onLinkClick={trackLandingLinkClick}
urlState={urlState}
/>
<SpyRoute pageName={SecurityPageName.rulesLanding} />
</SecuritySolutionPageWrapper>
</TrackApplicationView>
</PluginTemplateWrapper>
);
};

Some files were not shown because too many files have changed in this diff Show more