Hook for breadcrumbs (#76736) (#77352)

Remove `UpdateBreadcrumbs` and `ProvideBreadcrumbs` components and replace with `useBreadcrumbs` hook.

The logic and tests stay pretty much the same, but it's much clearer to see what's going on.

Also: 

* Put some of the shared route-related interfaces in /public/application/routes instead of /public/components/app/Main. I plan to move more of the routing-related code here in the future.
* Remove the `name` property for routes, since it wasn't being used.
* Rename `Breadcrumbroute` to `APMRouteDefinition`.

Part of #51963.
This commit is contained in:
Nathan L Smith 2020-09-15 11:27:11 -05:00 committed by GitHub
parent 4821b69158
commit 71e52d2e1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 304 additions and 467 deletions

View file

@ -4,52 +4,51 @@
* you may not use this file except in compliance with the Elastic License.
*/
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { AppMountParameters, CoreStart } from 'kibana/public';
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Router } from 'react-router-dom';
import styled, { ThemeProvider, DefaultTheme } from 'styled-components';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin';
import 'react-vis/dist/style.css';
import styled, { DefaultTheme, ThemeProvider } from 'styled-components';
import {
KibanaContextProvider,
useUiSetting$,
RedirectAppLinks,
useUiSetting$,
} from '../../../../../src/plugins/kibana_react/public';
import { px, units } from '../style/variables';
import { UpdateBreadcrumbs } from '../components/app/Main/UpdateBreadcrumbs';
import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange';
import 'react-vis/dist/style.css';
import { RumHome } from '../components/app/RumDashboard/RumHome';
import { ConfigSchema } from '../index';
import { BreadcrumbRoute } from '../components/app/Main/ProvideBreadcrumbs';
import { RouteName } from '../components/app/Main/route_config/route_names';
import { APMRouteDefinition } from '../application/routes';
import { renderAsRedirectTo } from '../components/app/Main/route_config';
import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange';
import { RumHome } from '../components/app/RumDashboard/RumHome';
import { ApmPluginContext } from '../context/ApmPluginContext';
import { UrlParamsProvider } from '../context/UrlParamsContext';
import { LoadingIndicatorProvider } from '../context/LoadingIndicatorContext';
import { UrlParamsProvider } from '../context/UrlParamsContext';
import { useBreadcrumbs } from '../hooks/use_breadcrumbs';
import { ConfigSchema } from '../index';
import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin';
import { createCallApmApi } from '../services/rest/createCallApmApi';
import { px, units } from '../style/variables';
const CsmMainContainer = styled.div`
padding: ${px(units.plus)};
height: 100%;
`;
export const rumRoutes: BreadcrumbRoute[] = [
export const rumRoutes: APMRouteDefinition[] = [
{
exact: true,
path: '/',
render: renderAsRedirectTo('/csm'),
breadcrumb: 'Client Side Monitoring',
name: RouteName.CSM,
},
];
function CsmApp() {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
useBreadcrumbs(rumRoutes);
return (
<ThemeProvider
theme={(outerTheme?: DefaultTheme) => ({
@ -59,7 +58,6 @@ function CsmApp() {
})}
>
<CsmMainContainer data-test-subj="csmMainContainer" role="main">
<UpdateBreadcrumbs routes={rumRoutes} />
<Route component={ScrollToTopOnPathChange} />
<RumHome />
</CsmMainContainer>

View file

@ -22,11 +22,11 @@ import {
import { AlertsContextProvider } from '../../../triggers_actions_ui/public';
import { routes } from '../components/app/Main/route_config';
import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange';
import { UpdateBreadcrumbs } from '../components/app/Main/UpdateBreadcrumbs';
import { ApmPluginContext } from '../context/ApmPluginContext';
import { LicenseProvider } from '../context/LicenseContext';
import { LoadingIndicatorProvider } from '../context/LoadingIndicatorContext';
import { UrlParamsProvider } from '../context/UrlParamsContext';
import { useBreadcrumbs } from '../hooks/use_breadcrumbs';
import { ApmPluginSetupDeps } from '../plugin';
import { createCallApmApi } from '../services/rest/createCallApmApi';
import { createStaticIndexPattern } from '../services/rest/index_pattern';
@ -42,6 +42,8 @@ const MainContainer = styled.div`
function App() {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
useBreadcrumbs(routes);
return (
<ThemeProvider
theme={(outerTheme?: DefaultTheme) => ({
@ -51,7 +53,6 @@ function App() {
})}
>
<MainContainer data-test-subj="apmMainContainer" role="main">
<UpdateBreadcrumbs routes={routes} />
<Route component={ScrollToTopOnPathChange} />
<Switch>
{routes.map((route, i) => (

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteComponentProps, RouteProps } from 'react-router-dom';
export type BreadcrumbTitle<T = {}> =
| string
| ((props: RouteComponentProps<T>) => string)
| null;
export interface APMRouteDefinition<T = any> extends RouteProps {
breadcrumb: BreadcrumbTitle<T>;
}

View file

@ -18,6 +18,7 @@ exports[`Home component should render services 1`] = `
"currentAppId$": Observable {
"_isScalar": false,
},
"navigateToUrl": [Function],
},
"chrome": Object {
"docTitle": Object {
@ -78,6 +79,7 @@ exports[`Home component should render traces 1`] = `
"currentAppId$": Observable {
"_isScalar": false,
},
"navigateToUrl": [Function],
},
"chrome": Object {
"docTitle": Object {

View file

@ -1,109 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Location } from 'history';
import { BreadcrumbRoute, getBreadcrumbs } from './ProvideBreadcrumbs';
import { RouteName } from './route_config/route_names';
describe('getBreadcrumbs', () => {
const getTestRoutes = (): BreadcrumbRoute[] => [
{ path: '/a', exact: true, breadcrumb: 'A', name: RouteName.HOME },
{
path: '/a/ignored',
exact: true,
breadcrumb: 'Ignored Route',
name: RouteName.METRICS,
},
{
path: '/a/:letter',
exact: true,
name: RouteName.SERVICE,
breadcrumb: ({ match }) => `Second level: ${match.params.letter}`,
},
{
path: '/a/:letter/c',
exact: true,
name: RouteName.ERRORS,
breadcrumb: ({ match }) => `Third level: ${match.params.letter}`,
},
];
const getLocation = () =>
({
pathname: '/a/b/c/',
} as Location);
it('should return a set of matching breadcrumbs for a given path', () => {
const breadcrumbs = getBreadcrumbs({
location: getLocation(),
routes: getTestRoutes(),
});
expect(breadcrumbs.map((b) => b.value)).toMatchInlineSnapshot(`
Array [
"A",
"Second level: b",
"Third level: b",
]
`);
});
it('should skip breadcrumbs if breadcrumb is null', () => {
const location = getLocation();
const routes = getTestRoutes();
routes[2].breadcrumb = null;
const breadcrumbs = getBreadcrumbs({
location,
routes,
});
expect(breadcrumbs.map((b) => b.value)).toMatchInlineSnapshot(`
Array [
"A",
"Third level: b",
]
`);
});
it('should skip breadcrumbs if breadcrumb key is missing', () => {
const location = getLocation();
const routes = getTestRoutes();
// @ts-expect-error
delete routes[2].breadcrumb;
const breadcrumbs = getBreadcrumbs({ location, routes });
expect(breadcrumbs.map((b) => b.value)).toMatchInlineSnapshot(`
Array [
"A",
"Third level: b",
]
`);
});
it('should produce matching breadcrumbs even if the pathname has a query string appended', () => {
const location = getLocation();
const routes = getTestRoutes();
location.pathname += '?some=thing';
const breadcrumbs = getBreadcrumbs({
location,
routes,
});
expect(breadcrumbs.map((b) => b.value)).toMatchInlineSnapshot(`
Array [
"A",
"Second level: b",
"Third level: b",
]
`);
});
});

View file

@ -1,135 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Location } from 'history';
import React from 'react';
import {
matchPath,
RouteComponentProps,
RouteProps,
withRouter,
} from 'react-router-dom';
import { RouteName } from './route_config/route_names';
type LocationMatch = Pick<
RouteComponentProps<Record<string, string>>,
'location' | 'match'
>;
type BreadcrumbFunction = (props: LocationMatch) => string;
export interface BreadcrumbRoute extends RouteProps {
breadcrumb: string | BreadcrumbFunction | null;
name: RouteName;
}
export interface Breadcrumb extends LocationMatch {
value: string;
}
interface RenderProps extends RouteComponentProps {
breadcrumbs: Breadcrumb[];
}
interface ProvideBreadcrumbsProps extends RouteComponentProps {
routes: BreadcrumbRoute[];
render: (props: RenderProps) => React.ReactElement<any> | null;
}
interface ParseOptions extends LocationMatch {
breadcrumb: string | BreadcrumbFunction;
}
const parse = (options: ParseOptions) => {
const { breadcrumb, match, location } = options;
let value;
if (typeof breadcrumb === 'function') {
value = breadcrumb({ match, location });
} else {
value = breadcrumb;
}
return { value, match, location };
};
export function getBreadcrumb({
location,
currentPath,
routes,
}: {
location: Location;
currentPath: string;
routes: BreadcrumbRoute[];
}) {
return routes.reduce<Breadcrumb | null>((found, { breadcrumb, ...route }) => {
if (found) {
return found;
}
if (!breadcrumb) {
return null;
}
const match = matchPath<Record<string, string>>(currentPath, route);
if (match) {
return parse({
breadcrumb,
match,
location,
});
}
return null;
}, null);
}
export function getBreadcrumbs({
routes,
location,
}: {
routes: BreadcrumbRoute[];
location: Location;
}) {
const breadcrumbs: Breadcrumb[] = [];
const { pathname } = location;
pathname
.split('?')[0]
.replace(/\/$/, '')
.split('/')
.reduce((acc, next) => {
// `/1/2/3` results in match checks for `/1`, `/1/2`, `/1/2/3`.
const currentPath = !next ? '/' : `${acc}/${next}`;
const breadcrumb = getBreadcrumb({
location,
currentPath,
routes,
});
if (breadcrumb) {
breadcrumbs.push(breadcrumb);
}
return currentPath === '/' ? '' : currentPath;
}, '');
return breadcrumbs;
}
function ProvideBreadcrumbsComponent({
routes = [],
render,
location,
match,
history,
}: ProvideBreadcrumbsProps) {
const breadcrumbs = getBreadcrumbs({ routes, location });
return render({ breadcrumbs, location, match, history });
}
export const ProvideBreadcrumbs = withRouter(ProvideBreadcrumbsComponent);

View file

@ -1,90 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Location } from 'history';
import React, { MouseEvent } from 'react';
import { CoreStart } from 'src/core/public';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { getAPMHref } from '../../shared/Links/apm/APMLink';
import {
Breadcrumb,
BreadcrumbRoute,
ProvideBreadcrumbs,
} from './ProvideBreadcrumbs';
interface Props {
location: Location;
breadcrumbs: Breadcrumb[];
core: CoreStart;
}
function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumb[]) {
return breadcrumbs.map(({ value }) => value).reverse();
}
class UpdateBreadcrumbsComponent extends React.Component<Props> {
public updateHeaderBreadcrumbs() {
const { basePath } = this.props.core.http;
const breadcrumbs = this.props.breadcrumbs.map(
({ value, match }, index) => {
const { search } = this.props.location;
const isLastBreadcrumbItem =
index === this.props.breadcrumbs.length - 1;
const href = isLastBreadcrumbItem
? undefined // makes the breadcrumb item not clickable
: getAPMHref({ basePath, path: match.url, search });
return {
text: value,
href,
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
if (href) {
event.preventDefault();
this.props.core.application.navigateToUrl(href);
}
},
};
}
);
this.props.core.chrome.docTitle.change(
getTitleFromBreadCrumbs(this.props.breadcrumbs)
);
this.props.core.chrome.setBreadcrumbs(breadcrumbs);
}
public componentDidMount() {
this.updateHeaderBreadcrumbs();
}
public componentDidUpdate() {
this.updateHeaderBreadcrumbs();
}
public render() {
return null;
}
}
interface UpdateBreadcrumbsProps {
routes: BreadcrumbRoute[];
}
export function UpdateBreadcrumbs({ routes }: UpdateBreadcrumbsProps) {
const { core } = useApmPluginContext();
return (
<ProvideBreadcrumbs
routes={routes}
render={({ breadcrumbs, location }) => (
<UpdateBreadcrumbsComponent
breadcrumbs={breadcrumbs}
location={location}
core={core}
/>
)}
/>
);
}

View file

@ -9,6 +9,7 @@ import React from 'react';
import { Redirect, RouteComponentProps } from 'react-router-dom';
import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n';
import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes';
import { APMRouteDefinition } from '../../../../application/routes';
import { toQuery } from '../../../shared/Links/url_helpers';
import { ErrorGroupDetails } from '../../ErrorGroupDetails';
import { Home } from '../../Home';
@ -21,12 +22,10 @@ import { ApmIndices } from '../../Settings/ApmIndices';
import { CustomizeUI } from '../../Settings/CustomizeUI';
import { TraceLink } from '../../TraceLink';
import { TransactionDetails } from '../../TransactionDetails';
import { BreadcrumbRoute } from '../ProvideBreadcrumbs';
import {
CreateAgentConfigurationRouteHandler,
EditAgentConfigurationRouteHandler,
} from './route_handlers/agent_configuration';
import { RouteName } from './route_names';
/**
* Given a path, redirect to that location, preserving the search and maintaining
@ -150,13 +149,12 @@ function SettingsCustomizeUI(props: RouteComponentProps<{}>) {
* The array of route definitions to be used when the application
* creates the routes.
*/
export const routes: BreadcrumbRoute[] = [
export const routes: APMRouteDefinition[] = [
{
exact: true,
path: '/',
component: renderAsRedirectTo('/services'),
breadcrumb: 'APM',
name: RouteName.HOME,
},
{
exact: true,
@ -165,7 +163,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', {
defaultMessage: 'Services',
}),
name: RouteName.SERVICES,
},
{
exact: true,
@ -174,7 +171,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', {
defaultMessage: 'Traces',
}),
name: RouteName.TRACES,
},
{
exact: true,
@ -183,7 +179,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', {
defaultMessage: 'Settings',
}),
name: RouteName.SETTINGS,
},
{
exact: true,
@ -192,7 +187,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', {
defaultMessage: 'Indices',
}),
name: RouteName.INDICES,
},
{
exact: true,
@ -202,7 +196,6 @@ export const routes: BreadcrumbRoute[] = [
'xpack.apm.breadcrumb.settings.agentConfigurationTitle',
{ defaultMessage: 'Agent Configuration' }
),
name: RouteName.AGENT_CONFIGURATION,
},
{
exact: true,
@ -211,7 +204,6 @@ export const routes: BreadcrumbRoute[] = [
'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle',
{ defaultMessage: 'Create Agent Configuration' }
),
name: RouteName.AGENT_CONFIGURATION_CREATE,
component: CreateAgentConfigurationRouteHandler,
},
{
@ -221,7 +213,6 @@ export const routes: BreadcrumbRoute[] = [
'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle',
{ defaultMessage: 'Edit Agent Configuration' }
),
name: RouteName.AGENT_CONFIGURATION_EDIT,
component: EditAgentConfigurationRouteHandler,
},
{
@ -232,16 +223,14 @@ export const routes: BreadcrumbRoute[] = [
renderAsRedirectTo(
`/services/${props.match.params.serviceName}/transactions`
)(props),
name: RouteName.SERVICE,
},
} as APMRouteDefinition<{ serviceName: string }>,
// errors
{
exact: true,
path: '/services/:serviceName/errors/:groupId',
component: ErrorGroupDetails,
breadcrumb: ({ match }) => match.params.groupId,
name: RouteName.ERROR,
},
} as APMRouteDefinition<{ groupId: string; serviceName: string }>,
{
exact: true,
path: '/services/:serviceName/errors',
@ -249,7 +238,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', {
defaultMessage: 'Errors',
}),
name: RouteName.ERRORS,
},
// transactions
{
@ -259,7 +247,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', {
defaultMessage: 'Transactions',
}),
name: RouteName.TRANSACTIONS,
},
// metrics
{
@ -269,7 +256,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.metricsTitle', {
defaultMessage: 'Metrics',
}),
name: RouteName.METRICS,
},
// service nodes, only enabled for java agents for now
{
@ -279,7 +265,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', {
defaultMessage: 'JVMs',
}),
name: RouteName.SERVICE_NODES,
},
// node metrics
{
@ -295,7 +280,6 @@ export const routes: BreadcrumbRoute[] = [
return serviceNodeName || '';
},
name: RouteName.SERVICE_NODE_METRICS,
},
{
exact: true,
@ -305,14 +289,12 @@ export const routes: BreadcrumbRoute[] = [
const query = toQuery(location.search);
return query.transactionName as string;
},
name: RouteName.TRANSACTION_NAME,
},
{
exact: true,
path: '/link-to/trace/:traceId',
component: TraceLink,
breadcrumb: null,
name: RouteName.LINK_TO_TRACE,
},
{
exact: true,
@ -321,7 +303,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', {
defaultMessage: 'Service Map',
}),
name: RouteName.SERVICE_MAP,
},
{
exact: true,
@ -330,7 +311,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', {
defaultMessage: 'Service Map',
}),
name: RouteName.SINGLE_SERVICE_MAP,
},
{
exact: true,
@ -339,7 +319,6 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', {
defaultMessage: 'Customize UI',
}),
name: RouteName.CUSTOMIZE_UI,
},
{
exact: true,
@ -351,6 +330,5 @@ export const routes: BreadcrumbRoute[] = [
defaultMessage: 'Anomaly detection',
}
),
name: RouteName.ANOMALY_DETECTION,
},
];

View file

@ -1,31 +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;
* you may not use this file except in compliance with the Elastic License.
*/
export enum RouteName {
HOME = 'home',
SERVICES = 'services',
SERVICE_MAP = 'service-map',
SINGLE_SERVICE_MAP = 'single-service-map',
TRACES = 'traces',
SERVICE = 'service',
TRANSACTIONS = 'transactions',
ERRORS = 'errors',
ERROR = 'error',
METRICS = 'metrics',
SERVICE_NODE_METRICS = 'node_metrics',
TRANSACTION_TYPE = 'transaction_type',
TRANSACTION_NAME = 'transaction_name',
SETTINGS = 'settings',
AGENT_CONFIGURATION = 'agent_configuration',
AGENT_CONFIGURATION_CREATE = 'agent_configuration_create',
AGENT_CONFIGURATION_EDIT = 'agent_configuration_edit',
INDICES = 'indices',
SERVICE_NODES = 'nodes',
LINK_TO_TRACE = 'link_to_trace',
CUSTOMIZE_UI = 'customize_ui',
ANOMALY_DETECTION = 'anomaly_detection',
CSM = 'csm',
}

View file

@ -39,6 +39,7 @@ const mockCore = {
apm: {},
},
currentAppId$: new Observable(),
navigateToUrl: (url: string) => {},
},
chrome: {
docTitle: { change: () => {} },

View file

@ -4,63 +4,56 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import produce from 'immer';
import React, { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ApmPluginContextValue } from '../../../context/ApmPluginContext';
import { routes } from './route_config';
import { UpdateBreadcrumbs } from './UpdateBreadcrumbs';
import { routes } from '../components/app/Main/route_config';
import { ApmPluginContextValue } from '../context/ApmPluginContext';
import {
MockApmPluginContextWrapper,
mockApmPluginContextValue,
} from '../../../context/ApmPluginContext/MockApmPluginContext';
MockApmPluginContextWrapper,
} from '../context/ApmPluginContext/MockApmPluginContext';
import { useBreadcrumbs } from './use_breadcrumbs';
const setBreadcrumbs = jest.fn();
const changeTitle = jest.fn();
function createWrapper(path: string) {
return ({ children }: { children?: ReactNode }) => {
const value = (produce(mockApmPluginContextValue, (draft) => {
draft.core.application.navigateToUrl = (url: string) => Promise.resolve();
draft.core.chrome.docTitle.change = changeTitle;
draft.core.chrome.setBreadcrumbs = setBreadcrumbs;
}) as unknown) as ApmPluginContextValue;
function mountBreadcrumb(route: string, params = '') {
mount(
<MockApmPluginContextWrapper
value={
({
...mockApmPluginContextValue,
core: {
...mockApmPluginContextValue.core,
chrome: {
...mockApmPluginContextValue.core.chrome,
docTitle: { change: changeTitle },
setBreadcrumbs,
},
},
} as unknown) as ApmPluginContextValue
}
>
<MemoryRouter initialEntries={[`${route}?kuery=myKuery&${params}`]}>
<UpdateBreadcrumbs routes={routes} />
return (
<MemoryRouter initialEntries={[path]}>
<MockApmPluginContextWrapper value={value}>
{children}
</MockApmPluginContextWrapper>
</MemoryRouter>
</MockApmPluginContextWrapper>
);
expect(setBreadcrumbs).toHaveBeenCalledTimes(1);
);
};
}
describe('UpdateBreadcrumbs', () => {
beforeEach(() => {
setBreadcrumbs.mockReset();
changeTitle.mockReset();
});
function mountBreadcrumb(path: string) {
renderHook(() => useBreadcrumbs(routes), { wrapper: createWrapper(path) });
}
it('Changes the homepage title', () => {
const changeTitle = jest.fn();
const setBreadcrumbs = jest.fn();
describe('useBreadcrumbs', () => {
it('changes the page title', () => {
mountBreadcrumb('/');
expect(changeTitle).toHaveBeenCalledWith(['APM']);
});
it('/services/:serviceName/errors/:groupId', () => {
test('/services/:serviceName/errors/:groupId', () => {
mountBreadcrumb(
'/services/opbeans-node/errors/myGroupId',
'rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0'
'/services/opbeans-node/errors/myGroupId?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0'
);
const breadcrumbs = setBreadcrumbs.mock.calls[0][0];
expect(breadcrumbs).toEqual(
expect(setBreadcrumbs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
text: 'APM',
@ -95,10 +88,10 @@ describe('UpdateBreadcrumbs', () => {
]);
});
it('/services/:serviceName/errors', () => {
mountBreadcrumb('/services/opbeans-node/errors');
const breadcrumbs = setBreadcrumbs.mock.calls[0][0];
expect(breadcrumbs).toEqual(
test('/services/:serviceName/errors', () => {
mountBreadcrumb('/services/opbeans-node/errors?kuery=myKuery');
expect(setBreadcrumbs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
text: 'APM',
@ -115,6 +108,7 @@ describe('UpdateBreadcrumbs', () => {
expect.objectContaining({ text: 'Errors', href: undefined }),
])
);
expect(changeTitle).toHaveBeenCalledWith([
'Errors',
'opbeans-node',
@ -123,10 +117,10 @@ describe('UpdateBreadcrumbs', () => {
]);
});
it('/services/:serviceName/transactions', () => {
mountBreadcrumb('/services/opbeans-node/transactions');
const breadcrumbs = setBreadcrumbs.mock.calls[0][0];
expect(breadcrumbs).toEqual(
test('/services/:serviceName/transactions', () => {
mountBreadcrumb('/services/opbeans-node/transactions?kuery=myKuery');
expect(setBreadcrumbs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
text: 'APM',
@ -152,14 +146,12 @@ describe('UpdateBreadcrumbs', () => {
]);
});
it('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => {
test('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => {
mountBreadcrumb(
'/services/opbeans-node/transactions/view',
'transactionName=my-transaction-name'
'/services/opbeans-node/transactions/view?kuery=myKuery&transactionName=my-transaction-name'
);
const breadcrumbs = setBreadcrumbs.mock.calls[0][0];
expect(breadcrumbs).toEqual(
expect(setBreadcrumbs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
text: 'APM',

View file

@ -0,0 +1,214 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { History, Location } from 'history';
import { ChromeBreadcrumb } from 'kibana/public';
import { MouseEvent, ReactNode, useEffect } from 'react';
import {
matchPath,
RouteComponentProps,
useHistory,
match as Match,
useLocation,
} from 'react-router-dom';
import { APMRouteDefinition, BreadcrumbTitle } from '../application/routes';
import { getAPMHref } from '../components/shared/Links/apm/APMLink';
import { useApmPluginContext } from './useApmPluginContext';
interface BreadcrumbWithoutLink extends ChromeBreadcrumb {
match: Match<Record<string, string>>;
}
interface BreadcrumbFunctionArgs extends RouteComponentProps {
breadcrumbTitle: BreadcrumbTitle;
}
/**
* Call the breadcrumb function if there is one, otherwise return it as a string
*/
function getBreadcrumbText({
breadcrumbTitle,
history,
location,
match,
}: BreadcrumbFunctionArgs) {
return typeof breadcrumbTitle === 'function'
? breadcrumbTitle({ history, location, match })
: breadcrumbTitle;
}
/**
* Get a breadcrumb from the current path and route definitions.
*/
function getBreadcrumb({
currentPath,
history,
location,
routes,
}: {
currentPath: string;
history: History;
location: Location;
routes: APMRouteDefinition[];
}) {
return routes.reduce<BreadcrumbWithoutLink | null>(
(found, { breadcrumb, ...routeDefinition }) => {
if (found) {
return found;
}
if (!breadcrumb) {
return null;
}
const match = matchPath<Record<string, string>>(
currentPath,
routeDefinition
);
if (match) {
return {
match,
text: getBreadcrumbText({
breadcrumbTitle: breadcrumb,
history,
location,
match,
}),
};
}
return null;
},
null
);
}
/**
* Once we have the breadcrumbs, we need to iterate through the list again to
* add the href and onClick, since we need to know which one is the final
* breadcrumb
*/
function addLinksToBreadcrumbs({
breadcrumbs,
navigateToUrl,
wrappedGetAPMHref,
}: {
breadcrumbs: BreadcrumbWithoutLink[];
navigateToUrl: (url: string) => Promise<void>;
wrappedGetAPMHref: (path: string) => string;
}) {
return breadcrumbs.map((breadcrumb, index) => {
const isLastBreadcrumbItem = index === breadcrumbs.length - 1;
// Make the link not clickable if it's the last item
const href = isLastBreadcrumbItem
? undefined
: wrappedGetAPMHref(breadcrumb.match.url);
const onClick = !href
? undefined
: (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigateToUrl(href);
};
return {
...breadcrumb,
match: undefined,
href,
onClick,
};
});
}
/**
* Convert a list of route definitions to a list of breadcrumbs
*/
function routeDefinitionsToBreadcrumbs({
history,
location,
routes,
}: {
history: History;
location: Location;
routes: APMRouteDefinition[];
}) {
const breadcrumbs: BreadcrumbWithoutLink[] = [];
const { pathname } = location;
pathname
.split('?')[0]
.replace(/\/$/, '')
.split('/')
.reduce((acc, next) => {
// `/1/2/3` results in match checks for `/1`, `/1/2`, `/1/2/3`.
const currentPath = !next ? '/' : `${acc}/${next}`;
const breadcrumb = getBreadcrumb({
currentPath,
history,
location,
routes,
});
if (breadcrumb) {
breadcrumbs.push(breadcrumb);
}
return currentPath === '/' ? '' : currentPath;
}, '');
return breadcrumbs;
}
/**
* Get an array for a page title from a list of breadcrumbs
*/
function getTitleFromBreadcrumbs(breadcrumbs: ChromeBreadcrumb[]): string[] {
function removeNonStrings(item: ReactNode): item is string {
return typeof item === 'string';
}
return breadcrumbs
.map(({ text }) => text)
.reverse()
.filter(removeNonStrings);
}
/**
* Determine the breadcrumbs from the routes, set them, and update the page
* title when the route changes.
*/
export function useBreadcrumbs(routes: APMRouteDefinition[]) {
const history = useHistory();
const location = useLocation();
const { search } = location;
const { core } = useApmPluginContext();
const { basePath } = core.http;
const { navigateToUrl } = core.application;
const { docTitle, setBreadcrumbs } = core.chrome;
const changeTitle = docTitle.change;
function wrappedGetAPMHref(path: string) {
return getAPMHref({ basePath, path, search });
}
const breadcrumbsWithoutLinks = routeDefinitionsToBreadcrumbs({
history,
location,
routes,
});
const breadcrumbs = addLinksToBreadcrumbs({
breadcrumbs: breadcrumbsWithoutLinks,
wrappedGetAPMHref,
navigateToUrl,
});
const title = getTitleFromBreadcrumbs(breadcrumbs);
useEffect(() => {
changeTitle(title);
setBreadcrumbs(breadcrumbs);
}, [breadcrumbs, changeTitle, location, title, setBreadcrumbs]);
}