From ab3839440ea3bd58bd0322199bb2e1f6864d3eab Mon Sep 17 00:00:00 2001 From: Pablo Neves Machado Date: Mon, 14 Aug 2023 10:44:02 +0200 Subject: [PATCH] Refactor useUpdateBrowserTitle to use pathname instead of SpyRoute --- .../common/hooks/use_update_browser_title.ts | 10 +-- .../links/use_find_app_links_by_path.test.ts | 70 +++++++++++++++++++ .../links/use_find_app_links_by_path.ts | 58 +++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.ts diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_update_browser_title.ts b/x-pack/plugins/security_solution/public/common/hooks/use_update_browser_title.ts index 9dc10d69a05eb1d..af119c11eb56064 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_update_browser_title.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_update_browser_title.ts @@ -6,14 +6,14 @@ */ import { useEffect } from 'react'; -import { getLinkInfo } from '../links'; -import { useRouteSpy } from '../utils/route/use_route_spy'; +import { useNavLinks } from '../links/nav_links'; +import { useFindAppLinksByPath } from '../links/use_find_app_links_by_path'; export const useUpdateBrowserTitle = () => { - const [{ pageName }] = useRouteSpy(); - const linkInfo = getLinkInfo(pageName); + const navLinks = useNavLinks(); + const linkInfo = useFindAppLinksByPath(navLinks); useEffect(() => { document.title = `${linkInfo?.title ?? ''} - Kibana`; - }, [pageName, linkInfo]); + }, [linkInfo]); }; diff --git a/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.test.ts b/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.test.ts new file mode 100644 index 000000000000000..aec2ecaa7638913 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { APP_PATH, SecurityPageName } from '../../../common'; +import { useFindAppLinksByPath } from './use_find_app_links_by_path'; + +const mockedGetAppUrl = jest + .fn() + .mockImplementation(({ deepLinkId }) => `${APP_PATH}/${deepLinkId}`); +const mockedUseLocation = jest.fn().mockReturnValue({ pathname: '/' }); + +jest.mock('../lib/kibana', () => ({ + useAppUrl: () => ({ + getAppUrl: mockedGetAppUrl, + }), + useBasePath: () => '', +})); + +jest.mock('react-router-dom', () => ({ + useLocation: () => mockedUseLocation(), +})); + +describe('useFindAppLinksByPath', () => { + it('returns null when navLinks is undefined', () => { + const { result } = renderHook(() => useFindAppLinksByPath(undefined)); + expect(result.current).toBe(null); + }); + it('returns null when navLinks is empty', () => { + const { result } = renderHook(() => useFindAppLinksByPath([])); + expect(result.current).toBe(null); + }); + + it('returns null when navLinks is not empty but does not match the current pathname', () => { + const { result } = renderHook(() => + useFindAppLinksByPath([{ id: SecurityPageName.hostsAnomalies, title: 'no page' }]) + ); + expect(result.current).toBe(null); + }); + + it('returns nav item when it matches the current pathname', () => { + const navItem = { id: SecurityPageName.users, title: 'Test User page' }; + mockedUseLocation.mockReturnValue({ pathname: '/users' }); + const { result } = renderHook(() => useFindAppLinksByPath([navItem])); + expect(result.current).toBe(navItem); + }); + + it('returns nav item when the pathname starts with the nav item url ', () => { + const navItem = { id: SecurityPageName.users, title: 'Test User page' }; + mockedUseLocation.mockReturnValue({ pathname: '/users/events' }); + const { result } = renderHook(() => useFindAppLinksByPath([navItem])); + expect(result.current).toBe(navItem); + }); + + it('returns leaf nav item when it matches the current pathname ', () => { + const leafNavItem = { id: SecurityPageName.usersEvents, title: 'Test User Events page' }; + const navItem = { + id: SecurityPageName.users, + title: 'Test User page', + links: [leafNavItem], + }; + mockedUseLocation.mockReturnValue({ pathname: '/users-events' }); + const { result } = renderHook(() => useFindAppLinksByPath([navItem])); + expect(result.current).toBe(leafNavItem); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.ts b/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.ts new file mode 100644 index 000000000000000..536feaea3f2ecf9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.ts @@ -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 { useCallback, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { APP_PATH } from '../../../common'; +import { useBasePath, useAppUrl } from '../lib/kibana'; +import type { NavigationLink } from './types'; + +/** + * It returns the first nav item that matches the current pathname. + * It compares the pathname and nav item using `startsWith`, + * meaning that the pathname: `/hosts/anomalies` matches the nav item URL `/hosts`. + */ +export const useFindAppLinksByPath = (navLinks: NavigationLink[] | undefined) => { + const { getAppUrl } = useAppUrl(); + const basePath = useBasePath(); + const { pathname } = useLocation(); + + const isCurrentPathItem = useCallback( + (navItem: NavigationLink) => { + const appUrl = getAppUrl({ deepLinkId: navItem.id }); + return `${basePath}${APP_PATH}${pathname}`.startsWith(appUrl); + }, + [basePath, getAppUrl, pathname] + ); + + return useMemo(() => findNavItem(isCurrentPathItem, navLinks), [navLinks, isCurrentPathItem]); +}; + +/** + * DFS to find the first nav item that matches the current pathname. + * Case the leaf node does not match the pathname; we return the nearest parent node that does. + * + * @param predicate calls predicate once for each element of the tree, until it finds one where predicate returns true. + */ +const findNavItem = ( + predicate: (navItem: NavigationLink) => boolean, + navItems: NavigationLink[] | undefined +): NavigationLink | null => { + if (!navItems) return null; + + for (const navItem of navItems) { + if (navItem.links?.length) { + const foundItem = findNavItem(predicate, navItem.links); + if (foundItem) return foundItem; + } + + if (predicate(navItem)) { + return navItem; + } + } + return null; +};