From a41efea31c288afdda328cd5d21729bdcf58a8a6 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Wed, 16 Aug 2023 08:18:11 +0200 Subject: [PATCH] [Security Solutions] Fix Serverless page title (#163911) ## Summary Refactor useUpdateBrowserTitle to use pathname instead of SpyRoute ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../common/hooks/use_update_browser_title.ts | 10 +-- .../links/use_find_app_links_by_path.test.ts | 83 +++++++++++++++++++ .../links/use_find_app_links_by_path.ts | 58 +++++++++++++ 3 files changed, 146 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 9dc10d69a05eb..af119c11eb560 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 0000000000000..2ee6b269832c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/use_find_app_links_by_path.test.ts @@ -0,0 +1,83 @@ +/* + * 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', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + 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); + }); + + it('should not confuse pages with similar names (users and users-risk)', () => { + const usersNavItem = { id: SecurityPageName.users, title: 'Test User page' }; + const usersRiskNavItem = { id: SecurityPageName.usersRisk, title: 'Test User Risk page' }; + + mockedUseLocation.mockReturnValue({ pathname: '/users-risk' }); + const { result } = renderHook(() => useFindAppLinksByPath([usersNavItem, usersRiskNavItem])); + expect(result.current).toBe(usersRiskNavItem); + }); +}); 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 0000000000000..6c762e9a6d5ac --- /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 { matchPath, 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 !!matchPath(`${basePath}${APP_PATH}${pathname}`, { path: appUrl, strict: false }); + }, + [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; +};