From bf6422ad56c5a09c7ebc1f8101e066ea10190329 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Thu, 31 Jan 2019 11:21:12 -0500 Subject: [PATCH 1/3] Adding draft version of withBreadcrumbs HOC with TS errors --- .../components/app/Main/withBreadcrumbs.tsx | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx diff --git a/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx new file mode 100644 index 0000000000000..73a2ce9ec9135 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx @@ -0,0 +1,98 @@ +/* + * 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 React from 'react'; +import { + matchPath, + RouteComponentProps, + RouteProps, + withRouter +} from 'react-router-dom'; + +export type BreadcrumbFunction = (props: RouteComponentProps) => string | null; + +export interface BreadcrumbRoute extends RouteProps { + breadcrumb: string | BreadcrumbFunction; +} + +const render = (props: RouteComponentProps) => { + const { breadcrumb, match, location } = props; + if (typeof breadcrumb === 'function') { + return breadcrumb(props); + } + + return { value: breadcrumb, match, location }; +}; + +const getBreadcrumb = ({ location, currentPath, routes }) => + routes.reduce((matchingBreadcrumb, { breadcrumb, path, ...rest }) => { + if (matchingBreadcrumb) { + return matchingBreadcrumb; + } + const match = matchPath(currentPath, { path, ...rest }); + + if (match) { + if (!breadcrumb) { + return null; + } + + return render({ + breadcrumb, + match, + location, + ...rest + }); + } + return null; + }, null); + +export const getBreadcrumbs = ({ + routes, + location, + options = {} +}): string[] => { + const matches = []; + 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, + ...options + }); + + if (breadcrumb) { + matches.push(breadcrumb); + } + + return currentPath === '/' ? '' : currentPath; + }, null); + + return matches; +}; + +interface Breadcrumb { + value: string; + match: { + url: string; + }; +} + +interface Props extends RouteComponentProps { + breadcrumbs: string[]; +} + +export function withBreadcrumbs(routes = []) { + return (Component: React.ComponentType) => + withRouter(props => ); +} From e1c818a8da42fa025e0d57e40ecf9157cd7f97f8 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Thu, 31 Jan 2019 16:29:04 -0500 Subject: [PATCH 2/3] ProvideBreadcrumbs implemented --- x-pack/package.json | 1 - .../app/Main/ProvideBreadcrumbs.tsx | 132 ++++++++++++++++++ ...teBreadcrumbs.ts => UpdateBreadcrumbs.tsx} | 47 +++---- .../components/app/Main/routeConfig.tsx | 29 +--- .../components/app/Main/withBreadcrumbs.tsx | 98 ------------- yarn.lock | 5 - 6 files changed, 157 insertions(+), 155 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx rename x-pack/plugins/apm/public/components/app/Main/{UpdateBreadcrumbs.ts => UpdateBreadcrumbs.tsx} (51%) delete mode 100644 x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx 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..163dae32fa1a5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx @@ -0,0 +1,132 @@ +/* + * 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 }; +}; + +const getBreadcrumb = ({ + location, + currentPath, + routes +}: { + location: Location; + currentPath: string; + routes: BreadcrumbRoute[]; +}) => + 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 const getBreadcrumbs = ({ + routes, + location +}: { + routes: BreadcrumbRoute[]; + location: Location; +}) => { + const matches: 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) { + matches.push(breadcrumb); + } + + return currentPath === '/' ? '' : currentPath; + }, ''); + + return matches; +}; + +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/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/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx deleted file mode 100644 index 73a2ce9ec9135..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/withBreadcrumbs.tsx +++ /dev/null @@ -1,98 +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 React from 'react'; -import { - matchPath, - RouteComponentProps, - RouteProps, - withRouter -} from 'react-router-dom'; - -export type BreadcrumbFunction = (props: RouteComponentProps) => string | null; - -export interface BreadcrumbRoute extends RouteProps { - breadcrumb: string | BreadcrumbFunction; -} - -const render = (props: RouteComponentProps) => { - const { breadcrumb, match, location } = props; - if (typeof breadcrumb === 'function') { - return breadcrumb(props); - } - - return { value: breadcrumb, match, location }; -}; - -const getBreadcrumb = ({ location, currentPath, routes }) => - routes.reduce((matchingBreadcrumb, { breadcrumb, path, ...rest }) => { - if (matchingBreadcrumb) { - return matchingBreadcrumb; - } - const match = matchPath(currentPath, { path, ...rest }); - - if (match) { - if (!breadcrumb) { - return null; - } - - return render({ - breadcrumb, - match, - location, - ...rest - }); - } - return null; - }, null); - -export const getBreadcrumbs = ({ - routes, - location, - options = {} -}): string[] => { - const matches = []; - 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, - ...options - }); - - if (breadcrumb) { - matches.push(breadcrumb); - } - - return currentPath === '/' ? '' : currentPath; - }, null); - - return matches; -}; - -interface Breadcrumb { - value: string; - match: { - url: string; - }; -} - -interface Props extends RouteComponentProps { - breadcrumbs: string[]; -} - -export function withBreadcrumbs(routes = []) { - return (Component: React.ComponentType) => - withRouter(props => ); -} 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" From d21e9ea08c2701f6734fc9c229a0a8bfb1d621dc Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 4 Feb 2019 16:01:03 -0500 Subject: [PATCH 3/3] Adds tests to provide breadcrumb logic --- .../app/Main/ProvideBreadcrumbs.tsx | 25 +++-- .../Main/__test__/ProvideBreadcrumbs.test.tsx | 104 ++++++++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx index 163dae32fa1a5..9fbfc29a80b4c 100644 --- a/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx @@ -24,7 +24,7 @@ export interface BreadcrumbRoute extends RouteProps { breadcrumb: string | BreadcrumbFunction | null; } -export interface Breadcrumb extends LocationMatch { +export interface Breadcrumb extends LocationMatch { value: string; } @@ -54,16 +54,16 @@ const parse = (options: ParseOptions) => { return { value, match, location }; }; -const getBreadcrumb = ({ +export function getBreadcrumb({ location, currentPath, routes }: { location: Location; currentPath: string; - routes: BreadcrumbRoute[]; -}) => - routes.reduce((found, { breadcrumb, ...route }) => { + routes: Array>; +}) { + return routes.reduce((found, { breadcrumb, ...route }) => { if (found) { return found; } @@ -84,15 +84,16 @@ const getBreadcrumb = ({ return null; }, null); +} -export const getBreadcrumbs = ({ +export function getBreadcrumbs({ routes, location }: { routes: BreadcrumbRoute[]; location: Location; -}) => { - const matches: Breadcrumb[] = []; +}) { + const breadcrumbs: Array> = []; const { pathname } = location; pathname @@ -102,21 +103,21 @@ export const getBreadcrumbs = ({ .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({ + const breadcrumb = getBreadcrumb({ location, currentPath, routes }); if (breadcrumb) { - matches.push(breadcrumb); + breadcrumbs.push(breadcrumb); } return currentPath === '/' ? '' : currentPath; }, ''); - return matches; -}; + return breadcrumbs; +} function ProvideBreadcrumbsComponent({ routes = [], 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", +] +`); + }); +});