Skip to content

Commit 5b39987

Browse files
committed
[Endpoint] Hook to handle events needing navigation via Router (#63863)
* new hook providing generic event handler for use with react router * Refactor of Header Naviagtion to use useNavigateByRouterEventHandler * Policy list refactor to use useNavigateByRouterEventHandler hook * Policy list Policy name link to use useNavigateByRouterEventHandler hook * Host list use of useNavigateByRouteEventHandler
1 parent d860bb8 commit 5b39987

11 files changed

Lines changed: 244 additions & 84 deletions

File tree

x-pack/plugins/endpoint/public/applications/endpoint/view/components/header_navigation.tsx

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import React, { MouseEvent, useMemo } from 'react';
7+
import React, { memo, useMemo } from 'react';
88
import { i18n } from '@kbn/i18n';
99
import { EuiTabs, EuiTab } from '@elastic/eui';
10-
import { useHistory, useLocation } from 'react-router-dom';
10+
import { useLocation } from 'react-router-dom';
1111
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
1212
import { Immutable } from '../../../../../common/types';
13+
import { useNavigateByRouterEventHandler } from '../hooks/use_navigate_by_router_event_handler';
1314

1415
interface NavTabs {
1516
name: string;
@@ -48,33 +49,30 @@ const navTabs: Immutable<NavTabs[]> = [
4849
},
4950
];
5051

51-
export const HeaderNavigation: React.FunctionComponent = React.memo(() => {
52-
const history = useHistory();
53-
const location = useLocation();
52+
const NavTab = memo<{ tab: NavTabs }>(({ tab }) => {
53+
const { pathname } = useLocation();
5454
const { services } = useKibana();
55+
const onClickHandler = useNavigateByRouterEventHandler(tab.href);
5556
const BASE_PATH = services.application.getUrlForApp('endpoint');
5657

58+
return (
59+
<EuiTab
60+
data-test-subj={`${tab.id}EndpointTab`}
61+
href={`${BASE_PATH}${tab.href}`}
62+
onClick={onClickHandler}
63+
isSelected={tab.href === pathname || (tab.href !== '/' && pathname.startsWith(tab.href))}
64+
>
65+
{tab.name}
66+
</EuiTab>
67+
);
68+
});
69+
70+
export const HeaderNavigation: React.FunctionComponent = React.memo(() => {
5771
const tabList = useMemo(() => {
5872
return navTabs.map((tab, index) => {
59-
return (
60-
<EuiTab
61-
data-test-subj={`${tab.id}EndpointTab`}
62-
key={index}
63-
href={`${BASE_PATH}${tab.href}`}
64-
onClick={(event: MouseEvent) => {
65-
event.preventDefault();
66-
history.push(tab.href);
67-
}}
68-
isSelected={
69-
tab.href === location.pathname ||
70-
(tab.href !== '/' && location.pathname.startsWith(tab.href))
71-
}
72-
>
73-
{tab.name}
74-
</EuiTab>
75-
);
73+
return <NavTab tab={tab} key={index} />;
7674
});
77-
}, [BASE_PATH, history, location.pathname]);
75+
}, []);
7876

7977
return <EuiTabs>{tabList}</EuiTabs>;
8078
});

x-pack/plugins/endpoint/public/applications/endpoint/view/components/link_to_app.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('LinkToApp component', () => {
110110
const clickEventArg = spyOnClickHandler.mock.calls[0][0];
111111
expect(clickEventArg.isDefaultPrevented()).toBe(true);
112112
});
113-
it('should not navigate if onClick callback prevents defalut', () => {
113+
it('should not navigate if onClick callback prevents default', () => {
114114
const spyOnClickHandler: LinkToAppOnClickMock = jest.fn(ev => {
115115
ev.preventDefault();
116116
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import React from 'react';
7+
import { AppContextTestRender, createAppRootMockRenderer } from '../../mocks';
8+
import { useNavigateByRouterEventHandler } from './use_navigate_by_router_event_handler';
9+
import { act, fireEvent, cleanup } from '@testing-library/react';
10+
11+
type ClickHandlerMock<Return = void> = jest.Mock<
12+
Return,
13+
[React.MouseEvent<HTMLAnchorElement, MouseEvent>]
14+
>;
15+
16+
describe('useNavigateByRouterEventHandler hook', () => {
17+
let render: AppContextTestRender['render'];
18+
let history: AppContextTestRender['history'];
19+
let renderResult: ReturnType<AppContextTestRender['render']>;
20+
let linkEle: HTMLAnchorElement;
21+
let clickHandlerSpy: ClickHandlerMock;
22+
const Link = React.memo<{
23+
routeTo: Parameters<typeof useNavigateByRouterEventHandler>[0];
24+
onClick?: Parameters<typeof useNavigateByRouterEventHandler>[1];
25+
}>(({ routeTo, onClick }) => {
26+
const onClickHandler = useNavigateByRouterEventHandler(routeTo, onClick);
27+
return (
28+
<a href="/mock/path" onClick={onClickHandler}>
29+
mock link
30+
</a>
31+
);
32+
});
33+
34+
beforeEach(async () => {
35+
({ render, history } = createAppRootMockRenderer());
36+
clickHandlerSpy = jest.fn();
37+
renderResult = render(<Link routeTo="/mock/path" onClick={clickHandlerSpy} />);
38+
linkEle = (await renderResult.findByText('mock link')) as HTMLAnchorElement;
39+
});
40+
afterEach(cleanup);
41+
42+
it('should navigate to path via Router', () => {
43+
const containerClickSpy = jest.fn();
44+
renderResult.container.addEventListener('click', containerClickSpy);
45+
expect(history.location.pathname).not.toEqual('/mock/path');
46+
act(() => {
47+
fireEvent.click(linkEle);
48+
});
49+
expect(containerClickSpy.mock.calls[0][0].defaultPrevented).toBe(true);
50+
expect(history.location.pathname).toEqual('/mock/path');
51+
renderResult.container.removeEventListener('click', containerClickSpy);
52+
});
53+
it('should support onClick prop', () => {
54+
act(() => {
55+
fireEvent.click(linkEle);
56+
});
57+
expect(clickHandlerSpy).toHaveBeenCalled();
58+
expect(history.location.pathname).toEqual('/mock/path');
59+
});
60+
it('should not navigate if preventDefault is true', () => {
61+
clickHandlerSpy.mockImplementation(event => {
62+
event.preventDefault();
63+
});
64+
act(() => {
65+
fireEvent.click(linkEle);
66+
});
67+
expect(history.location.pathname).not.toEqual('/mock/path');
68+
});
69+
it('should not navigate via router if click was not the primary mouse button', async () => {
70+
act(() => {
71+
fireEvent.click(linkEle, { button: 2 });
72+
});
73+
expect(history.location.pathname).not.toEqual('/mock/path');
74+
});
75+
it('should not navigate via router if anchor has target', () => {
76+
linkEle.setAttribute('target', '_top');
77+
act(() => {
78+
fireEvent.click(linkEle, { button: 2 });
79+
});
80+
expect(history.location.pathname).not.toEqual('/mock/path');
81+
});
82+
it('should not to navigate if meta|alt|ctrl|shift keys are pressed', () => {
83+
['meta', 'alt', 'ctrl', 'shift'].forEach(key => {
84+
act(() => {
85+
fireEvent.click(linkEle, { [`${key}Key`]: true });
86+
});
87+
expect(history.location.pathname).not.toEqual('/mock/path');
88+
});
89+
});
90+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { MouseEventHandler, useCallback } from 'react';
8+
import { useHistory } from 'react-router-dom';
9+
import { LocationDescriptorObject } from 'history';
10+
11+
type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
12+
13+
/**
14+
* Provides an event handler that can be used with (for example) `onClick` props to prevent the
15+
* event's default behaviour and instead navigate to to a route via the Router
16+
*
17+
* @param routeTo
18+
* @param onClick
19+
*/
20+
export const useNavigateByRouterEventHandler = (
21+
routeTo: string | [string, unknown] | LocationDescriptorObject<unknown>, // Cover the calling signature of `history.push()`
22+
23+
/** Additional onClick callback */
24+
onClick?: EventHandlerCallback
25+
): EventHandlerCallback => {
26+
const history = useHistory();
27+
return useCallback(
28+
ev => {
29+
try {
30+
if (onClick) {
31+
onClick(ev);
32+
}
33+
} catch (error) {
34+
ev.preventDefault();
35+
throw error;
36+
}
37+
38+
if (ev.defaultPrevented) {
39+
return;
40+
}
41+
42+
if (ev.button !== 0) {
43+
return;
44+
}
45+
46+
if (
47+
ev.currentTarget instanceof HTMLAnchorElement &&
48+
ev.currentTarget.target !== '' &&
49+
ev.currentTarget.target !== '_self'
50+
) {
51+
return;
52+
}
53+
54+
if (ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) {
55+
return;
56+
}
57+
58+
ev.preventDefault();
59+
60+
if (Array.isArray(routeTo)) {
61+
history.push(...routeTo);
62+
} else if (typeof routeTo === 'string') {
63+
history.push(routeTo);
64+
} else {
65+
history.push(routeTo);
66+
}
67+
},
68+
[history, onClick, routeTo]
69+
);
70+
};

x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import React, { memo } from 'react';
7+
import React, { memo, MouseEventHandler } from 'react';
88
import { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui';
99
import styled from 'styled-components';
1010

1111
export type FlyoutSubHeaderProps = CommonProps & {
1212
children: React.ReactNode;
1313
backButton?: {
1414
title: string;
15-
onClick: (event: React.MouseEvent) => void;
15+
onClick: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
1616
href?: string;
1717
};
1818
};

x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ import {
1616
import React, { memo, useMemo } from 'react';
1717
import { FormattedMessage } from '@kbn/i18n/react';
1818
import { i18n } from '@kbn/i18n';
19-
import { useHistory } from 'react-router-dom';
2019
import { HostMetadata } from '../../../../../../common/types';
2120
import { FormattedDateAndTime } from '../../formatted_date_time';
2221
import { LinkToApp } from '../../components/link_to_app';
2322
import { useHostListSelector, useHostLogsUrl } from '../hooks';
2423
import { urlFromQueryParams } from '../url_from_query_params';
2524
import { uiQueryParams } from '../../../store/hosts/selectors';
25+
import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler';
2626

2727
const HostIds = styled(EuiListGroupItem)`
2828
margin-top: 0;
@@ -34,7 +34,6 @@ const HostIds = styled(EuiListGroupItem)`
3434
export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
3535
const { appId, appPath, url } = useHostLogsUrl(details.host.id);
3636
const queryParams = useHostListSelector(uiQueryParams);
37-
const history = useHistory();
3837
const detailsResultsUpper = useMemo(() => {
3938
return [
4039
{
@@ -65,6 +64,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
6564
show: 'policy_response',
6665
});
6766
}, [details.host.id, queryParams]);
67+
const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseUri);
6868

6969
const detailsResultsLower = useMemo(() => {
7070
return [
@@ -84,10 +84,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
8484
<EuiLink
8585
data-test-subj="policyStatusValue"
8686
href={'?' + policyResponseUri.search}
87-
onClick={(ev: React.MouseEvent) => {
88-
ev.preventDefault();
89-
history.push(policyResponseUri);
90-
}}
87+
onClick={policyStatusClickHandler}
9188
>
9289
<FormattedMessage
9390
id="xpack.endpoint.host.details.policyStatus.success"
@@ -127,8 +124,8 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
127124
details.endpoint.policy.id,
128125
details.host.hostname,
129126
details.host.ip,
130-
history,
131-
policyResponseUri,
127+
policyResponseUri.search,
128+
policyStatusClickHandler,
132129
]);
133130

134131
return (

x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { HostDetails } from './host_details';
2424
import { PolicyResponse } from './policy_response';
2525
import { HostMetadata } from '../../../../../../common/types';
2626
import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header';
27+
import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler';
2728

2829
export const HostDetailsFlyout = memo(() => {
2930
const history = useHistory();
@@ -92,24 +93,25 @@ export const HostDetailsFlyout = memo(() => {
9293
const PolicyResponseFlyoutPanel = memo<{
9394
hostMeta: HostMetadata;
9495
}>(({ hostMeta }) => {
95-
const history = useHistory();
9696
const { show, ...queryParams } = useHostListSelector(uiQueryParams);
97+
const detailsUri = useMemo(
98+
() =>
99+
urlFromQueryParams({
100+
...queryParams,
101+
selected_host: hostMeta.host.id,
102+
}),
103+
[hostMeta.host.id, queryParams]
104+
);
105+
const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsUri);
97106
const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => {
98-
const detailsUri = urlFromQueryParams({
99-
...queryParams,
100-
selected_host: hostMeta.host.id,
101-
});
102107
return {
103108
title: i18n.translate('xpack.endpoint.host.policyResponse.backLinkTitle', {
104109
defaultMessage: 'Endpoint Details',
105110
}),
106111
href: '?' + detailsUri.search,
107-
onClick: ev => {
108-
ev.preventDefault();
109-
history.push(detailsUri);
110-
},
112+
onClick: backToDetailsClickHandler,
111113
};
112-
}, [history, hostMeta.host.id, queryParams]);
114+
}, [backToDetailsClickHandler, detailsUri.search]);
113115

114116
return (
115117
<>

0 commit comments

Comments
 (0)