diff --git a/x-pack/package.json b/x-pack/package.json index 48d39046fbf97..99aa051239b00 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -233,7 +233,6 @@ "react-portal": "^3.2.0", "react-redux": "^5.0.7", "react-redux-request": "^1.5.6", - "react-router-breadcrumbs-hoc": "1.1.2", "react-router-dom": "^4.3.1", "react-select": "^1.2.1", "react-shortcuts": "^2.0.0", diff --git a/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx new file mode 100644 index 0000000000000..9fbfc29a80b4c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx @@ -0,0 +1,133 @@ +/* + * 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'; + +type LocationMatch = Pick< + RouteComponentProps, + 'location' | 'match' +>; + +export type BreadcrumbFunction = (props: LocationMatch) => string; + +export interface BreadcrumbRoute extends RouteProps { + breadcrumb: string | BreadcrumbFunction | null; +} + +export interface Breadcrumb extends LocationMatch { + value: string; +} + +export interface RenderProps extends RouteComponentProps { + breadcrumbs: Breadcrumb[]; +} + +export interface ProvideBreadcrumbsProps extends RouteComponentProps { + routes: BreadcrumbRoute[]; + render: (props: RenderProps) => React.ReactElement | 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: Array>; +}) { + return routes.reduce((found, { breadcrumb, ...route }) => { + if (found) { + return found; + } + + if (!breadcrumb) { + return null; + } + + const match = matchPath(currentPath, route); + + if (match) { + return parse({ + breadcrumb, + match, + location + }); + } + + return null; + }, null); +} + +export function getBreadcrumbs({ + routes, + location +}: { + routes: BreadcrumbRoute[]; + location: Location; +}) { + const breadcrumbs: Array> = []; + 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); diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.ts b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx similarity index 51% rename from x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.ts rename to x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx index dfa8669841414..f3751030df968 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.ts +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx @@ -5,40 +5,25 @@ */ import { Location } from 'history'; -import { flatten, last } from 'lodash'; +import { last } from 'lodash'; import React from 'react'; -// @ts-ignore -import { withBreadcrumbs } from 'react-router-breadcrumbs-hoc'; import chrome from 'ui/chrome'; import { toQuery } from '../../shared/Links/url_helpers'; -import { BreadcrumbFunction, BreadcrumbProps, routes } from './routeConfig'; - -interface BreadcrumbElement { - type: BreadcrumbFunction; - props: BreadcrumbProps; -} +import { Breadcrumb, ProvideBreadcrumbs } from './ProvideBreadcrumbs'; +import { routes } from './routeConfig'; interface Props { location: Location; - breadcrumbs: Array<{ - breadcrumb: string | BreadcrumbElement; - match: { - url: string; - }; - }>; + breadcrumbs: Breadcrumb[]; } class UpdateBreadcrumbsComponent extends React.Component { public updateHeaderBreadcrumbs() { const { _g = '', kuery = '' } = toQuery(this.props.location.search); - const breadcrumbs = this.props.breadcrumbs.map(({ breadcrumb, match }) => { - const text = - typeof breadcrumb === 'string' - ? breadcrumb - : breadcrumb.type(breadcrumb.props); // "render" the element - - return { text, href: `#${match.url}?_g=${_g}&kuery=${kuery}` }; - }); + const breadcrumbs = this.props.breadcrumbs.map(({ value, match }) => ({ + text: value, + href: `#${match.url}?_g=${_g}&kuery=${kuery}` + })); document.title = last(breadcrumbs).text; chrome.breadcrumbs.set(breadcrumbs); @@ -57,12 +42,16 @@ class UpdateBreadcrumbsComponent extends React.Component { } } -const flatRoutes = flatten( - routes.map(route => (route.switchRoutes ? route.switchRoutes : route)) -); - -const UpdateBreadcrumbs = withBreadcrumbs(flatRoutes)( - UpdateBreadcrumbsComponent +const UpdateBreadcrumbs = () => ( + ( + + )} + /> ); export { UpdateBreadcrumbs }; diff --git a/x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx new file mode 100644 index 0000000000000..cf4883ef570b8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx @@ -0,0 +1,104 @@ +/* + * 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'; + +interface TestParams { + letter: string; +} + +describe('getBreadcrumbs', () => { + const getTestRoutes = (): Array> => [ + { path: '/a', exact: true, breadcrumb: 'A' }, + { path: '/a/ignored', exact: true, breadcrumb: 'Ignored Route' }, + { + path: '/a/:letter', + exact: true, + breadcrumb: ({ match }) => `Second level: ${match.params.letter}` + }, + { + path: '/a/:letter/c', + exact: true, + 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(); + + 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", +] +`); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/Main/routeConfig.tsx b/x-pack/plugins/apm/public/components/app/Main/routeConfig.tsx index 9182d324a7333..d089dc5d8caad 100644 --- a/x-pack/plugins/apm/public/components/app/Main/routeConfig.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/routeConfig.tsx @@ -6,34 +6,19 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import { Redirect, RouteComponentProps, RouteProps } from 'react-router-dom'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; import { legacyDecodeURIComponent } from 'x-pack/plugins/apm/public/components/shared/Links/url_helpers'; -import { StringMap } from '../../../../typings/common'; // @ts-ignore import ErrorGroupDetails from '../ErrorGroupDetails'; import { ServiceDetails } from '../ServiceDetails'; import { TransactionDetails } from '../TransactionDetails'; import { Home } from './Home'; - -export interface BreadcrumbProps { - value: string; - match: { - url: string; - params: StringMap; - }; -} +import { BreadcrumbRoute } from './ProvideBreadcrumbs'; interface RouteParams { serviceName: string; } -export type BreadcrumbFunction = (props: BreadcrumbProps) => string; - -interface Route extends RouteProps { - path: string; - breadcrumb: string | BreadcrumbFunction | null; -} - const renderAsRedirectTo = (to: string) => { return ({ location }: RouteComponentProps) => ( { ); }; -export const routes: Route[] = [ +export const routes: BreadcrumbRoute[] = [ { exact: true, path: '/', @@ -85,7 +70,7 @@ export const routes: Route[] = [ { exact: true, path: '/:serviceName', - breadcrumb: ({ match }: BreadcrumbProps) => match.params.serviceName, + breadcrumb: ({ match }) => match.params.serviceName, render: (props: RouteComponentProps) => renderAsRedirectTo(`/${props.match.params.serviceName}/transactions`)( props @@ -95,7 +80,7 @@ export const routes: Route[] = [ exact: true, path: '/:serviceName/errors/:groupId', component: ErrorGroupDetails, - breadcrumb: ({ match }: BreadcrumbProps) => match.params.groupId + breadcrumb: ({ match }) => match.params.groupId }, { exact: true, @@ -133,7 +118,7 @@ export const routes: Route[] = [ exact: true, path: '/:serviceName/transactions/:transactionType/:transactionName', component: TransactionDetails, - breadcrumb: ({ match }: BreadcrumbProps) => - legacyDecodeURIComponent(match.params.transactionName) + breadcrumb: ({ match }) => + legacyDecodeURIComponent(match.params.transactionName) || '' } ]; diff --git a/yarn.lock b/yarn.lock index 66dc27e569585..307487001360e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17791,11 +17791,6 @@ react-resizable@1.x: prop-types "15.x" react-draggable "^2.2.6 || ^3.0.3" -react-router-breadcrumbs-hoc@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/react-router-breadcrumbs-hoc/-/react-router-breadcrumbs-hoc-1.1.2.tgz#4fafb620e7c6b876d98f7151f4c85ae5c3157dc0" - integrity sha1-T6+2IOfGuHbZj3FR9Mha5cMVfcA= - react-router-dom@4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.2.2.tgz#c8a81df3adc58bba8a76782e946cbd4eae649b8d"