diff --git a/config/serverless.security.yml b/config/serverless.security.yml index a5e1c170ddccd..ed910784c5940 100644 --- a/config/serverless.security.yml +++ b/config/serverless.security.yml @@ -1,3 +1,4 @@ +enterpriseSearch.enabled: false xpack.apm.enabled: false xpack.canvas.enabled: false xpack.observability.enabled: false diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/solution/header.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/solution/header.tsx index 7536371eb4a19..9ae12243d1461 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/solution/header.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/solution/header.tsx @@ -7,16 +7,8 @@ */ import React from 'react'; -import { - EuiButtonIcon, - EuiCollapsibleNav, - EuiHeader, - EuiHeaderLogo, - EuiHeaderSection, - EuiHeaderSectionItem, - EuiThemeProvider, - useEuiTheme, -} from '@elastic/eui'; +import { Router } from 'react-router-dom'; +import { EuiHeader, EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem } from '@elastic/eui'; import { ChromeBreadcrumb, ChromeGlobalHelpExtensionMenuLink, @@ -51,8 +43,6 @@ export const SolutionHeader = ({ navigation, ...observables }: Props) => { - const { euiTheme, colorMode } = useEuiTheme(); - const renderLogo = () => ( - - {}} - closeButtonProps={{ iconType: 'menuLeft' }} - showButtonIfDocked={true} - isDocked={true} - size={248} - hideCloseButton={false} - button={ - - - - } - > - {navigation} - - + {navigation} ); }; diff --git a/x-pack/plugins/security_solution/common/index.ts b/x-pack/plugins/security_solution/common/index.ts index 85546afccb694..3133996d79ed8 100644 --- a/x-pack/plugins/security_solution/common/index.ts +++ b/x-pack/plugins/security_solution/common/index.ts @@ -8,6 +8,7 @@ // TODO(jbudz): should be removed when upgrading to TS@4.8 // this is a skip for the errors created when typechecking with isolatedModules export {}; +export { APP_UI_ID, SecurityPageName } from './constants'; export { ELASTIC_SECURITY_RULE_ID } from './detection_engine/constants'; // Careful of exporting anything from this file as any file(s) you export here will cause your page bundle size to increase. diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 87d43742a9433..6c6d0da1c031e 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -78,7 +78,7 @@ import { USERS_PATH, } from '../../../common/constants'; import type { ExperimentalFeatures } from '../../../common/experimental_features'; -import { hasCapabilities, subscribeAppLinks } from '../../common/links'; +import { appLinks$, hasCapabilities } from '../../common/links'; import type { AppLinkItems } from '../../common/links/types'; export const FEATURE = { @@ -630,7 +630,7 @@ const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => * Registers any change in appLinks to be updated in app deepLinks */ export const registerDeepLinksUpdater = (appUpdater$: Subject): Subscription => { - return subscribeAppLinks((appLinks) => { + return appLinks$.subscribe((appLinks) => { appUpdater$.next(() => ({ navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update deepLinks: formatDeepLinks(appLinks), diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts deleted file mode 100644 index c44873414ca11..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts +++ /dev/null @@ -1,95 +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 { renderHook } from '@testing-library/react-hooks'; -import { SecurityPageName } from '../../../app/types'; -import type { AppLinkItems } from '../../links'; -import { TestProviders } from '../../mock'; -import { useAppNavLinks, useAppRootNavLink } from './nav_links'; -import type { NavLinkItem } from './types'; - -const mockNavLinks: AppLinkItems = [ - { - description: 'description', - id: SecurityPageName.administration, - links: [ - { - description: 'description 2', - id: SecurityPageName.endpoints, - links: [], - path: '/path_2', - title: 'title 2', - sideNavDisabled: true, - landingIcon: 'someicon', - landingImage: 'someimage', - skipUrlState: true, - }, - ], - path: '/path', - title: 'title', - }, -]; - -jest.mock('../../links', () => ({ - useAppLinks: () => mockNavLinks, -})); - -const renderUseAppNavLinks = () => - renderHook<{}, NavLinkItem[]>(() => useAppNavLinks(), { wrapper: TestProviders }); - -const renderUseAppRootNavLink = (id: SecurityPageName) => - renderHook<{ id: SecurityPageName }, NavLinkItem | undefined>(() => useAppRootNavLink(id), { - wrapper: TestProviders, - }); - -describe('useAppNavLinks', () => { - it('should return all nav links', () => { - const { result } = renderUseAppNavLinks(); - expect(result.current).toMatchInlineSnapshot(` - Array [ - Object { - "description": "description", - "id": "administration", - "links": Array [ - Object { - "description": "description 2", - "disabled": true, - "icon": "someicon", - "id": "endpoints", - "image": "someimage", - "skipUrlState": true, - "title": "title 2", - }, - ], - "title": "title", - }, - ] - `); - }); - - it('should return a root nav links', () => { - const { result } = renderUseAppRootNavLink(SecurityPageName.administration); - expect(result.current).toMatchInlineSnapshot(` - Object { - "description": "description", - "id": "administration", - "links": Array [ - Object { - "description": "description 2", - "disabled": true, - "icon": "someicon", - "id": "endpoints", - "image": "someimage", - "skipUrlState": true, - "title": "title 2", - }, - ], - "title": "title", - } - `); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx index f647ff1f873e1..b34e069c24860 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -13,7 +13,7 @@ import { SecurityPageName } from '../../../../app/types'; import { getAncestorLinksInfo } from '../../../links'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; import { useGetSecuritySolutionLinkProps } from '../../links'; -import { useAppNavLinks } from '../nav_links'; +import { useNavLinks } from '../../../links/nav_links'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; import { useIsPolicySettingsBarVisible } from '../../../../management/pages/policy/view/policy_hooks'; import { track } from '../../../lib/telemetry'; @@ -30,7 +30,7 @@ const isGetStartedNavItem = (id: SecurityPageName) => id === SecurityPageName.la * Returns the formatted `items` and `footerItems` to be rendered in the navigation */ const useSolutionSideNavItems = () => { - const navLinks = useAppNavLinks(); + const navLinks = useNavLinks(); const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props const sideNavItems = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 29f69700afdb7..a6ef5704e6680 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,9 +5,7 @@ * 2.0. */ -import type { IconType } from '@elastic/eui'; import { SecurityPageName } from '../../../app/types'; -import type { LinkCategories } from '../../links'; export type SearchNavTab = NavTab | { urlKey: UrlStateType; isDetailPage: boolean }; @@ -101,18 +99,3 @@ export interface SecuritySolutionTabNavigationProps { } export type NavigateToUrl = (url: string) => void; -export interface NavLinkItem { - categories?: LinkCategories; - description?: string; - disabled?: boolean; - icon?: IconType; - id: SecurityPageName; - links?: NavLinkItem[]; - image?: string; - title: string; - skipUrlState?: boolean; - isBeta?: boolean; - betaOptions?: { - text: string; - }; -} diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index de30840d02d9d..be1f206e339b2 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -7,7 +7,8 @@ import type { Capabilities } from '@kbn/core/public'; import { get, isArray } from 'lodash'; -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject } from 'rxjs'; import type { SecurityPageName } from '../../../common/constants'; import type { @@ -20,72 +21,48 @@ import type { } from './types'; /** - * App links updater, it keeps the value of the app links in sync with all application. - * It can be updated using `updateAppLinks` or `excludeAppLink` - * Read it using `subscribeAppLinks` or `useAppLinks` hook. + * App links updater, it stores the `appLinkItems` 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 appLinksUpdater$ = new BehaviorSubject<{ - links: AppLinkItems; - normalizedLinks: NormalizedLinks; -}>({ - links: [], // stores the appLinkItems recursive hierarchy - normalizedLinks: {}, // stores a flatten normalized object for direct id access -}); +const appLinksUpdater$ = new BehaviorSubject([]); +// stores a flatten normalized appLinkItems object for internal direct id access +const normalizedAppLinksUpdater$ = new BehaviorSubject({}); -const getAppLinksValue = (): AppLinkItems => appLinksUpdater$.getValue().links; -const getNormalizedLinksValue = (): NormalizedLinks => appLinksUpdater$.getValue().normalizedLinks; +// AppLinks observable +export const appLinks$ = appLinksUpdater$.asObservable(); /** - * Subscribes to the updater to get the app links updates + * Updates the app links applying the filter by permissions */ -export const subscribeAppLinks = (onChange: (links: AppLinkItems) => void) => - appLinksUpdater$.subscribe(({ links }) => onChange(links)); +export const updateAppLinks = ( + appLinksToUpdate: AppLinkItems, + linksPermissions: LinksPermissions +) => { + const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next(Object.freeze(filteredAppLinks)); + normalizedAppLinksUpdater$.next(Object.freeze(getNormalizedLinks(filteredAppLinks))); +}; /** * Hook to get the app links updated value */ -export const useAppLinks = (): AppLinkItems => { - const [appLinks, setAppLinks] = useState(getAppLinksValue); - - useEffect(() => { - const linksSubscription = subscribeAppLinks((newAppLinks) => { - setAppLinks(newAppLinks); - }); - return () => linksSubscription.unsubscribe(); - }, []); - - return appLinks; -}; +export const useAppLinks = (): AppLinkItems => + useObservable(appLinksUpdater$, appLinksUpdater$.getValue()); +/** + * Hook to get the normalized app links updated value + */ +export const useNormalizedAppLinks = (): NormalizedLinks => + useObservable(normalizedAppLinksUpdater$, normalizedAppLinksUpdater$.getValue()); /** * Hook to check if a link exists in the application links, * It can be used to know if a link access is authorized. */ export const useLinkExists = (id: SecurityPageName): boolean => { - const [linkExists, setLinkExists] = useState(!!getNormalizedLink(id)); - - useEffect(() => { - const linksSubscription = subscribeAppLinks(() => { - setLinkExists(!!getNormalizedLink(id)); - }); - return () => linksSubscription.unsubscribe(); - }, [id]); - - return linkExists; -}; - -/** - * Updates the app links applying the filter by permissions - */ -export const updateAppLinks = ( - appLinksToUpdate: AppLinkItems, - linksPermissions: LinksPermissions -) => { - const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); - appLinksUpdater$.next({ - links: Object.freeze(filteredAppLinks), - normalizedLinks: Object.freeze(getNormalizedLinks(filteredAppLinks)), - }); + const normalizedLinks = useNormalizedAppLinks(); + return useMemo(() => !!normalizedLinks[id], [normalizedLinks, id]); }; /** @@ -128,6 +105,10 @@ export const needsUrlState = (id: SecurityPageName): boolean => { return !getNormalizedLink(id)?.skipUrlState; }; +export const getLinksWithHiddenTimeline = (): LinkInfo[] => { + return Object.values(normalizedAppLinksUpdater$.getValue()).filter((link) => link.hideTimeline); +}; + // Internal functions /** @@ -136,8 +117,8 @@ export const needsUrlState = (id: SecurityPageName): boolean => { const getNormalizedLinks = ( currentLinks: AppLinkItems, parentId?: SecurityPageName -): NormalizedLinks => { - return currentLinks.reduce((normalized, { links, ...currentLink }) => { +): NormalizedLinks => + currentLinks.reduce((normalized, { links, ...currentLink }) => { normalized[currentLink.id] = { ...currentLink, parentId, @@ -147,10 +128,9 @@ const getNormalizedLinks = ( } return normalized; }, {}); -}; const getNormalizedLink = (id: SecurityPageName): Readonly | undefined => - getNormalizedLinksValue()[id]; + normalizedAppLinksUpdater$.getValue()[id]; const getFilteredAppLinks = ( appLinkToFilter: AppLinkItems, @@ -226,7 +206,3 @@ const isLinkAllowed = ( } return true; }; - -export const getLinksWithHiddenTimeline = (): LinkInfo[] => { - return Object.values(getNormalizedLinksValue()).filter((link) => link.hideTimeline); -}; diff --git a/x-pack/plugins/security_solution/public/common/links/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/links/nav_links.test.ts new file mode 100644 index 0000000000000..d8decac43a86a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/nav_links.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { AppLinkItems } from './types'; +import { formatNavigationLinks } from './nav_links'; +import { SecurityPageName } from '../../app/types'; + +const mockNavLinks: AppLinkItems = [ + { + description: 'description', + id: SecurityPageName.administration, + links: [ + { + description: 'description 2', + id: SecurityPageName.endpoints, + links: [], + path: '/path_2', + title: 'title 2', + sideNavDisabled: true, + landingIcon: 'someicon', + landingImage: 'someimage', + skipUrlState: true, + }, + ], + path: '/path', + title: 'title', + }, +]; + +describe('formatNavigationLinks', () => { + it('should format links', () => { + expect(formatNavigationLinks(mockNavLinks)).toMatchInlineSnapshot(` + Array [ + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/links/nav_links.ts similarity index 52% rename from x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts rename to x-pack/plugins/security_solution/public/common/links/nav_links.ts index 5fff0a9649940..0882bdf233601 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/nav_links.ts @@ -5,23 +5,13 @@ * 2.0. */ -import { useMemo } from 'react'; -import { useAppLinks } from '../../links'; -import type { SecurityPageName } from '../../../app/types'; -import type { NavLinkItem } from './types'; -import type { AppLinkItems } from '../../links/types'; +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs'; +import { appLinks$ } from './links'; +import type { SecurityPageName } from '../../app/types'; +import type { AppLinkItems, NavigationLink } from './types'; -export const useAppNavLinks = (): NavLinkItem[] => { - const appLinks = useAppLinks(); - const navLinks = useMemo(() => formatNavLinkItems(appLinks), [appLinks]); - return navLinks; -}; - -export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { - return useAppNavLinks().find(({ id }) => id === linkId); -}; - -const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => +export const formatNavigationLinks = (appLinks: AppLinkItems): NavigationLink[] => appLinks.map((link) => ({ id: link.id, title: link.title, @@ -33,9 +23,21 @@ const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), ...(link.isBeta != null ? { isBeta: link.isBeta } : {}), ...(link.betaOptions != null ? { betaOptions: link.betaOptions } : {}), - ...(link.links && link.links.length - ? { - links: formatNavLinkItems(link.links), - } - : {}), + ...(link.links?.length && { + links: formatNavigationLinks(link.links), + }), })); + +/** + * Navigation links observable based on Security AppLinks, + * It is used to generate the side navigation items + */ +export const navLinks$ = appLinks$.pipe(map(formatNavigationLinks)); + +export const useNavLinks = (): NavigationLink[] => { + return useObservable(navLinks$, []); +}; + +export const useRootNavLink = (linkId: SecurityPageName): NavigationLink | undefined => { + return useNavLinks().find(({ id }) => id === linkId); +}; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index f9a2c5776262f..162415fb66a16 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -131,3 +131,19 @@ export type AppLinkItems = Readonly; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; export type NormalizedLinks = Partial>; + +export interface NavigationLink { + categories?: LinkCategories; + description?: string; + disabled?: boolean; + icon?: IconType; + id: SecurityPageName; + links?: NavigationLink[]; + image?: string; + title: string; + skipUrlState?: boolean; + isBeta?: boolean; + betaOptions?: { + text: string; + }; +} diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx index 97eb89695bc98..112786b861df3 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx @@ -10,9 +10,9 @@ import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; import { DashboardsLandingPage } from './dashboards'; -import type { NavLinkItem } from '../../common/components/navigation/types'; import { useCapabilities } from '../../common/lib/kibana'; import * as telemetry from '../../common/lib/telemetry'; +import type { NavigationLink } from '../../common/links'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); @@ -28,7 +28,7 @@ const spyTrack = jest.spyOn(telemetry, 'track'); const OVERVIEW_ITEM_LABEL = 'Overview'; const DETECTION_RESPONSE_ITEM_LABEL = 'Detection & Response'; -const APP_DASHBOARD_LINKS: NavLinkItem = { +const APP_DASHBOARD_LINKS: NavigationLink = { id: SecurityPageName.dashboardsLanding, title: 'Dashboards', links: [ @@ -49,8 +49,8 @@ const APP_DASHBOARD_LINKS: NavLinkItem = { const URL = '/path/to/dashboards'; const mockAppManageLink = jest.fn(() => APP_DASHBOARD_LINKS); -jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: () => mockAppManageLink(), +jest.mock('../../common/links/nav_links', () => ({ + useRootNavLink: () => mockAppManageLink(), })); const CREATE_DASHBOARD_LINK = { isLoading: false, url: URL }; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx index ee97955b659ab..1bd7b69adcb72 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx @@ -18,7 +18,7 @@ import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { SecurityPageName } from '../../app/types'; import { DashboardsTable } from '../../common/components/dashboards/dashboards_table'; import { Title } from '../../common/components/header_page/title'; -import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; +import { useRootNavLink } from '../../common/links/nav_links'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { useCreateSecurityDashboardLink } from '../../common/containers/dashboards/use_create_security_dashboard_link'; import { useCapabilities, useNavigateTo } from '../../common/lib/kibana'; @@ -60,7 +60,7 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard }; export const DashboardsLandingPage = () => { - const dashboardLinks = useAppRootNavLink(SecurityPageName.dashboardsLanding)?.links ?? []; + const dashboardLinks = useRootNavLink(SecurityPageName.dashboardsLanding)?.links ?? []; const { show: canReadDashboard, createNew: canCreateDashboard } = useCapabilities(LEGACY_DASHBOARD_APP_ID); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/explore.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/explore.tsx index 17a0d7569b965..26dd3009e1d03 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/explore.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/explore.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; -import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; +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 '../components/landing_links_images'; import { EXPLORE_PAGE_TITLE } from './translations'; export const ExploreLandingPage = () => { - const exploreLinks = useAppRootNavLink(SecurityPageName.exploreLanding)?.links ?? []; + const exploreLinks = useRootNavLink(SecurityPageName.exploreLanding)?.links ?? []; return ( diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index 67eb06b60cca6..e900fad75546a 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -10,14 +10,14 @@ import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; import { ManagementCategories } from './manage'; -import type { NavLinkItem } from '../../common/components/navigation/types'; +import type { NavigationLink } from '../../common/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: NavLinkItem = { +const defaultAppManageLink: NavigationLink = { id: SecurityPageName.administration, title: 'admin', categories: [ @@ -47,8 +47,8 @@ const defaultAppManageLink: NavLinkItem = { }; const mockAppManageLink = jest.fn(() => defaultAppManageLink); -jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: () => mockAppManageLink(), +jest.mock('../../common/links/nav_links', () => ({ + useRootNavLink: () => mockAppManageLink(), })); describe('ManagementCategories', () => { diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index cb77921a0b673..37e2391801cac 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -10,8 +10,8 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; -import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import type { NavLinkItem } from '../../common/components/navigation/types'; +import { useRootNavLink } from '../../common/links/nav_links'; +import type { NavigationLink } from '../../common/links'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { LandingLinksIcons } from '../components/landing_links_icons'; @@ -30,14 +30,14 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.euiSizeL}; `; -type ManagementCategories = Array<{ label: string; links: NavLinkItem[] }>; +type ManagementCategories = Array<{ label: string; links: NavigationLink[] }>; const useManagementCategories = (): ManagementCategories => { - const { links = [], categories = [] } = useAppRootNavLink(SecurityPageName.administration) ?? {}; + const { links = [], categories = [] } = useRootNavLink(SecurityPageName.administration) ?? {}; const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link])); return categories.reduce((acc, { label, linkIds }) => { - const linksItem = linkIds.reduce((linksAcc, linkId) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { if (manageLinksById[linkId]) { linksAcc.push(manageLinksById[linkId]); } diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 89c6c0c86d6cc..d7655744869ca 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -45,6 +45,7 @@ import { import { getDeepLinks, registerDeepLinksUpdater } from './app/deep_links'; import type { LinksPermissions } from './common/links'; import { updateAppLinks } from './common/links'; +import { navLinks$ } from './common/links/nav_links'; import { licenseService } from './common/hooks/use_license'; import type { SecuritySolutionUiConfigType } from './common/types'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; @@ -249,7 +250,7 @@ export class Plugin implements IPlugin void; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginStart {} +export interface PluginStart { + navLinks$: Observable; +} export interface AppObservableLibs { kibana: CoreStart; diff --git a/x-pack/plugins/serverless_security/kibana.jsonc b/x-pack/plugins/serverless_security/kibana.jsonc index 71405b0e47707..99def198e6b46 100644 --- a/x-pack/plugins/serverless_security/kibana.jsonc +++ b/x-pack/plugins/serverless_security/kibana.jsonc @@ -15,7 +15,8 @@ "requiredPlugins": [ "serverless", "security", - "securitySolution" + "securitySolution", + "kibanaReact" ], "optionalPlugins": [], "requiredBundles": [] diff --git a/x-pack/plugins/serverless_security/public/components/side_navigation/index.ts b/x-pack/plugins/serverless_security/public/components/side_navigation/index.ts new file mode 100644 index 0000000000000..6ac2942940587 --- /dev/null +++ b/x-pack/plugins/serverless_security/public/components/side_navigation/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SecuritySideNavigation } from './side_navigation'; diff --git a/x-pack/plugins/serverless_security/public/components/side_navigation/side_navigation.tsx b/x-pack/plugins/serverless_security/public/components/side_navigation/side_navigation.tsx new file mode 100644 index 0000000000000..bc1c4a1c453a6 --- /dev/null +++ b/x-pack/plugins/serverless_security/public/components/side_navigation/side_navigation.tsx @@ -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, { useCallback } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { + EuiButtonIcon, + EuiCollapsibleNav, + EuiLoadingSpinner, + EuiThemeProvider, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SolutionNav } from '@kbn/shared-ux-page-solution-nav'; +import { SolutionSideNav } from '@kbn/security-solution-side-nav'; +import { + usePartitionFooterNavItems, + useSideNavItems, + useSideNavSelectedId, +} from '../../hooks/use_side_nav_items'; + +const LOCAL_STORAGE_IS_OPEN_KEY = 'SECURITY_SERVERLESS_SIDE_NAVIGATION_OPEN' as const; + +const translatedNavTitle = i18n.translate('xpack.securityServerless.navigation.mainLabel', { + defaultMessage: 'Security', +}); + +export const SecuritySideNavigation: React.FC = () => { + const { euiTheme, colorMode } = useEuiTheme(); + const sideNavItems = useSideNavItems(); + const selectedId = useSideNavSelectedId(sideNavItems); + const [items, footerItems] = usePartitionFooterNavItems(sideNavItems); + + const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true); + + const toggleOpen = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen, setIsOpen]); + + const isLoading = items.length === 0 && footerItems.length === 0; + + return ( + + + + + } + > + {isOpen && + (isLoading ? ( + + ) : ( + + } + closeFlyoutButtonPosition={'inside'} + /> + ))} + + + ); +}; diff --git a/x-pack/plugins/serverless_security/public/hooks/use_link_props.ts b/x-pack/plugins/serverless_security/public/hooks/use_link_props.ts new file mode 100644 index 0000000000000..6cdaef0d94d31 --- /dev/null +++ b/x-pack/plugins/serverless_security/public/hooks/use_link_props.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { APP_UI_ID, type SecurityPageName } from '@kbn/security-solution-plugin/common'; +import { useMemo, useCallback, type MouseEventHandler, type MouseEvent } from 'react'; +import { useKibana, type Services } from '../services'; + +interface LinkProps { + onClick: MouseEventHandler; + href: string; +} + +interface GetLinkPropsParams { + deepLinkId?: SecurityPageName; + path?: string; + appId?: string; + onClick?: MouseEventHandler; +} + +type GetLinkProps = (params: GetLinkPropsParams) => LinkProps; + +export const useLinkProps: GetLinkProps = (props) => { + const { application } = useKibana().services; + return useMemo(() => getLinkProps({ ...props, application }), [application, props]); +}; + +export const useGetLinkProps: () => GetLinkProps = () => { + const { application } = useKibana().services; + return useCallback( + (props) => getLinkProps({ ...props, application }), + [application] + ); +}; + +const getLinkProps = ({ + deepLinkId, + path, + onClick: onClickProps, + appId = APP_UI_ID, + application, +}: GetLinkPropsParams & { application: Services['application'] }): LinkProps => { + const { getUrlForApp, navigateToUrl } = application; + const url = getUrlForApp(appId, { deepLinkId, path }); + return { + href: url, + onClick: (ev) => { + if (isModifiedEvent(ev)) { + return; + } + + ev.preventDefault(); + navigateToUrl(url); + if (onClickProps) { + onClickProps(ev); + } + }, + }; +}; + +const isModifiedEvent = (event: MouseEvent) => + event.metaKey || event.altKey || event.ctrlKey || event.shiftKey; diff --git a/x-pack/plugins/serverless_security/public/hooks/use_side_nav_items.ts b/x-pack/plugins/serverless_security/public/hooks/use_side_nav_items.ts new file mode 100644 index 0000000000000..58a2bd04ad1b5 --- /dev/null +++ b/x-pack/plugins/serverless_security/public/hooks/use_side_nav_items.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { matchPath, useLocation } from 'react-router-dom'; +import { partition } from 'lodash/fp'; +import { SecurityPageName } from '@kbn/security-solution-plugin/common'; +import type { SideNavItem } from '@kbn/shared-ux-side-navigation'; +import { useKibana } from '../services'; +import { useGetLinkProps } from './use_link_props'; + +const isFooterNavItem = (id: string) => + id === SecurityPageName.landing || id === SecurityPageName.administration; + +const isGetStartedNavItem = (id: string) => id === SecurityPageName.landing; + +// DFS for the sideNavItem matching the current `pathname`, returns all item hierarchy when found +const findItemsByPath = (sideNavItems: SideNavItem[], pathname: string): SideNavItem[] => { + for (const sideNavItem of sideNavItems) { + if (sideNavItem.items?.length) { + const found = findItemsByPath(sideNavItem.items, pathname); + if (found.length) { + found.unshift(sideNavItem); + return found; + } + } + if (matchPath(pathname, { path: sideNavItem.href })) { + return [sideNavItem]; + } + } + return []; +}; + +/** + * Returns all the formatted SideNavItems, including external links + */ +export const useSideNavItems = (): SideNavItem[] => { + const { securitySolution } = useKibana().services; + const navLinks = useObservable(securitySolution.navLinks$, []); + const getLinkProps = useGetLinkProps(); + + const securitySideNavItems = useMemo( + () => + navLinks.reduce((items, navLink) => { + if (navLink.disabled) { + return items; + } + if (isGetStartedNavItem(navLink.id)) { + items.push({ + id: navLink.id, + label: navLink.title.toUpperCase(), + ...getLinkProps({ deepLinkId: navLink.id }), + labelSize: 'xs', + iconType: 'launch', + appendSeparator: true, + }); + } else { + // default sideNavItem formatting + items.push({ + id: navLink.id, + label: navLink.title, + ...getLinkProps({ deepLinkId: navLink.id }), + ...(navLink.categories?.length && { categories: navLink.categories }), + ...(navLink.links?.length && { + items: navLink.links.reduce((acc, current) => { + if (!current.disabled) { + acc.push({ + id: current.id, + label: current.title, + description: current.description, + isBeta: current.isBeta, + betaOptions: current.betaOptions, + ...getLinkProps({ deepLinkId: current.id }), + }); + } + return acc; + }, []), + }), + }); + } + return items; + }, []), + [getLinkProps, navLinks] + ); + + const sideNavItems = useAddExternalSideNavItems(securitySideNavItems); + + return sideNavItems; +}; + +/** + * @param securitySideNavItems the sideNavItems for Security pages + * @returns sideNavItems with Security and external links + */ +const useAddExternalSideNavItems = (securitySideNavItems: SideNavItem[]) => { + const getLinkProps = useGetLinkProps(); + + const sideNavItemsWithExternals = useMemo( + () => [ + ...securitySideNavItems, + { + id: 'discover', + label: 'Discover', + ...getLinkProps({ appId: 'discover' }), + }, + ], + [securitySideNavItems, getLinkProps] + ); + + return sideNavItemsWithExternals; +}; + +/** + * Partitions the sideNavItems into main and footer SideNavItems + * @param sideNavItems array for all SideNavItems + * @returns `[items, footerItems]` to be used in the side navigation component + */ +export const usePartitionFooterNavItems = ( + sideNavItems: SideNavItem[] +): [SideNavItem[], SideNavItem[]] => + useMemo(() => partition((item) => !isFooterNavItem(item.id), sideNavItems), [sideNavItems]); + +/** + * Returns the selected item id, which is the root item in the links hierarchy + */ +export const useSideNavSelectedId = (sideNavItems: SideNavItem[]): string => { + const { http } = useKibana().services; + const { pathname } = useLocation(); + + const selectedId: string = useMemo(() => { + const [rootNavItem] = findItemsByPath(sideNavItems, http.basePath.prepend(pathname)); + return rootNavItem?.id ?? ''; + }, [sideNavItems, pathname, http]); + + return selectedId; +}; diff --git a/x-pack/plugins/serverless_security/public/plugin.tsx b/x-pack/plugins/serverless_security/public/plugin.tsx index f0f1fd2e0e9f0..2d163df117c05 100644 --- a/x-pack/plugins/serverless_security/public/plugin.tsx +++ b/x-pack/plugins/serverless_security/public/plugin.tsx @@ -13,6 +13,8 @@ import { ServerlessSecurityPluginSetupDependencies, ServerlessSecurityPluginStartDependencies, } from './types'; +import { SecuritySideNavigation } from './components/side_navigation'; +import { getKibanaServicesProvider } from './services'; export class ServerlessSecurityPlugin implements @@ -32,10 +34,17 @@ export class ServerlessSecurityPlugin } public start( - _core: CoreStart, + core: CoreStart, startDeps: ServerlessSecurityPluginStartDependencies ): ServerlessSecurityPluginStart { - startDeps.serverless.setServerlessNavigation(

Security

); + const KibanaServicesProvider = getKibanaServicesProvider(core, startDeps); + + startDeps.serverless.setServerlessNavigation( + + + + ); + return {}; } diff --git a/x-pack/plugins/serverless_security/public/services.tsx b/x-pack/plugins/serverless_security/public/services.tsx new file mode 100644 index 0000000000000..4071da69967d2 --- /dev/null +++ b/x-pack/plugins/serverless_security/public/services.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import React from 'react'; +import { + KibanaContextProvider, + useKibana as useKibanaReact, +} from '@kbn/kibana-react-plugin/public'; +import type { ServerlessSecurityPluginStartDependencies } from './types'; + +export type Services = CoreStart & ServerlessSecurityPluginStartDependencies; + +export const getKibanaServicesProvider = ( + core: CoreStart, + pluginsStart: ServerlessSecurityPluginStartDependencies +): React.FC => { + const services: Services = { ...core, ...pluginsStart }; + return ({ children }) => { + return {children}; + }; +}; + +export const useKibana = () => useKibanaReact();