diff --git a/package.json b/package.json index 7dd0e74d75759..2ee46c031fbf0 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", + "@kbn/typed-react-router-config": "link:bazel-bin/packages/kbn-typed-react-router-config", "@kbn/std": "link:bazel-bin/packages/kbn-std", "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", @@ -177,6 +178,7 @@ "@turf/distance": "6.0.1", "@turf/helpers": "6.0.1", "@turf/length": "^6.0.2", + "@types/react-router-config": "^5.0.2", "@types/redux-logger": "^3.0.8", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", @@ -356,6 +358,7 @@ "react-resize-detector": "^4.2.0", "react-reverse-portal": "^1.0.4", "react-router": "^5.2.0", + "react-router-config": "^5.1.1", "react-router-dom": "^5.2.0", "react-router-redux": "^4.0.8", "react-shortcuts": "^2.0.0", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index de7a27fd51276..938afdc205a44 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -56,6 +56,7 @@ filegroup( "//packages/kbn-test:build", "//packages/kbn-test-subj-selector:build", "//packages/kbn-tinymath:build", + "//packages/kbn-typed-react-router-config:build", "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps:build", "//packages/kbn-utility-types:build", diff --git a/packages/kbn-io-ts-utils/src/deep_exact_rt/index.test.ts b/packages/kbn-io-ts-utils/src/deep_exact_rt/index.test.ts new file mode 100644 index 0000000000000..fe09fb442799c --- /dev/null +++ b/packages/kbn-io-ts-utils/src/deep_exact_rt/index.test.ts @@ -0,0 +1,73 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { deepExactRt } from '.'; +import { mergeRt } from '../merge_rt'; + +describe('deepExactRt', () => { + it('recursively wraps partial/interface types in t.exact', () => { + const a = t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + foo: t.string, + }), + }); + + const b = t.type({ + path: t.type({ + transactionType: t.string, + }), + }); + + const merged = mergeRt(a, b); + + expect( + deepExactRt(a).decode({ + path: { + serviceName: '', + transactionType: '', + }, + query: { + foo: '', + bar: '', + }, + // @ts-ignore + }).right + ).toEqual({ path: { serviceName: '' }, query: { foo: '' } }); + + expect( + deepExactRt(b).decode({ + path: { + serviceName: '', + transactionType: '', + }, + query: { + foo: '', + bar: '', + }, + // @ts-ignore + }).right + ).toEqual({ path: { transactionType: '' } }); + + expect( + deepExactRt(merged).decode({ + path: { + serviceName: '', + transactionType: '', + }, + query: { + foo: '', + bar: '', + }, + // @ts-ignore + }).right + ).toEqual({ path: { serviceName: '', transactionType: '' }, query: { foo: '' } }); + }); +}); diff --git a/packages/kbn-io-ts-utils/src/deep_exact_rt/index.ts b/packages/kbn-io-ts-utils/src/deep_exact_rt/index.ts new file mode 100644 index 0000000000000..8ebb9bbdd52f9 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/deep_exact_rt/index.ts @@ -0,0 +1,45 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { mapValues } from 'lodash'; +import { mergeRt } from '../merge_rt'; +import { isParsableType, ParseableType } from '../parseable_types'; + +export function deepExactRt | ParseableType>(type: T): T; + +export function deepExactRt(type: t.Type | ParseableType) { + if (!isParsableType(type)) { + return type; + } + + switch (type._tag) { + case 'ArrayType': + return t.array(deepExactRt(type.type)); + + case 'DictionaryType': + return t.dictionary(type.domain, deepExactRt(type.codomain)); + + case 'InterfaceType': + return t.exact(t.interface(mapValues(type.props, deepExactRt))); + + case 'PartialType': + return t.exact(t.partial(mapValues(type.props, deepExactRt))); + + case 'IntersectionType': + return t.intersection(type.types.map(deepExactRt) as any); + + case 'UnionType': + return t.union(type.types.map(deepExactRt) as any); + + case 'MergeType': + return mergeRt(deepExactRt(type.types[0]), deepExactRt(type.types[1])); + + default: + return type; + } +} diff --git a/packages/kbn-io-ts-utils/src/parseable_types/index.ts b/packages/kbn-io-ts-utils/src/parseable_types/index.ts new file mode 100644 index 0000000000000..089717ad8891b --- /dev/null +++ b/packages/kbn-io-ts-utils/src/parseable_types/index.ts @@ -0,0 +1,39 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { MergeType } from '../merge_rt'; + +export type ParseableType = + | t.StringType + | t.NumberType + | t.BooleanType + | t.ArrayType + | t.RecordC + | t.DictionaryType + | t.InterfaceType + | t.PartialType + | t.UnionType + | t.IntersectionType + | MergeType; + +const parseableTags = [ + 'StringType', + 'NumberType', + 'BooleanType', + 'ArrayType', + 'DictionaryType', + 'InterfaceType', + 'PartialType', + 'UnionType', + 'IntersectionType', + 'MergeType', +]; + +export const isParsableType = (type: t.Type | ParseableType): type is ParseableType => { + return '_tag' in type && parseableTags.includes(type._tag); +}; diff --git a/packages/kbn-io-ts-utils/src/to_json_schema/index.ts b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts index fc196a7c3123e..702c0150d07f7 100644 --- a/packages/kbn-io-ts-utils/src/to_json_schema/index.ts +++ b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts @@ -7,35 +7,7 @@ */ import * as t from 'io-ts'; import { mapValues } from 'lodash'; - -type JSONSchemableValueType = - | t.StringType - | t.NumberType - | t.BooleanType - | t.ArrayType - | t.RecordC - | t.DictionaryType - | t.InterfaceType - | t.PartialType - | t.UnionType - | t.IntersectionType; - -const tags = [ - 'StringType', - 'NumberType', - 'BooleanType', - 'ArrayType', - 'DictionaryType', - 'InterfaceType', - 'PartialType', - 'UnionType', - 'IntersectionType', -]; - -const isSchemableValueType = (type: t.Mixed): type is JSONSchemableValueType => { - // @ts-ignore - return tags.includes(type._tag); -}; +import { isParsableType } from '../parseable_types'; interface JSONSchemaObject { type: 'object'; @@ -74,7 +46,7 @@ type JSONSchema = | JSONSchemaAnyOf; export const toJsonSchema = (type: t.Mixed): JSONSchema => { - if (isSchemableValueType(type)) { + if (isParsableType(type)) { switch (type._tag) { case 'ArrayType': return { type: 'array', items: toJsonSchema(type.type) }; diff --git a/packages/kbn-typed-react-router-config/BUILD.bazel b/packages/kbn-typed-react-router-config/BUILD.bazel new file mode 100644 index 0000000000000..90f1acf43d3e7 --- /dev/null +++ b/packages/kbn-typed-react-router-config/BUILD.bazel @@ -0,0 +1,113 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-typed-react-router-config" +PKG_REQUIRE_NAME = "@kbn/typed-react-router-config" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + ], + exclude = [ + "**/*.test.*", + ] +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//tslib", + "@npm//utility-types", + "@npm//io-ts", + "@npm//query-string", + "@npm//react-router-config", + "@npm//react-router-dom", + "//packages/kbn-io-ts-utils", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react-router-config", + "@npm//@types/react-router-dom", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_config( + name = "tsconfig_browser", + src = "tsconfig.browser.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.browser.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_dir = "target_types", + declaration_map = True, + incremental = True, + out_dir = "target_node", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +ts_project( + name = "tsc_browser", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = False, + incremental = True, + out_dir = "target_web", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig_browser", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc", ":tsc_browser"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-typed-react-router-config/jest.config.js b/packages/kbn-typed-react-router-config/jest.config.js new file mode 100644 index 0000000000000..3a6b09c5677db --- /dev/null +++ b/packages/kbn-typed-react-router-config/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-typed-react-router-config'], +}; diff --git a/packages/kbn-typed-react-router-config/package.json b/packages/kbn-typed-react-router-config/package.json new file mode 100644 index 0000000000000..50c2e4b5d7e89 --- /dev/null +++ b/packages/kbn-typed-react-router-config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@kbn/typed-react-router-config", + "main": "target_node/index.js", + "types": "target_types/index.d.ts", + "browser": "target_web/index.js", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true +} diff --git a/packages/kbn-typed-react-router-config/src/create_router.test.tsx b/packages/kbn-typed-react-router-config/src/create_router.test.tsx new file mode 100644 index 0000000000000..49f6961fa3a85 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/create_router.test.tsx @@ -0,0 +1,239 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import * as t from 'io-ts'; +import { toNumberRt } from '@kbn/io-ts-utils/target/to_number_rt'; +import { createRouter } from './create_router'; +import { createMemoryHistory } from 'history'; +import { route } from './route'; + +describe('createRouter', () => { + const routes = route([ + { + path: '/', + element: <>, + children: [ + { + path: '/', + element: <>, + params: t.type({ + query: t.type({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + children: [ + { + path: '/services', + element: <>, + params: t.type({ + query: t.type({ + transactionType: t.string, + }), + }), + }, + { + path: '/services/:serviceName', + element: <>, + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.type({ + transactionType: t.string, + environment: t.string, + }), + }), + }, + { + path: '/traces', + element: <>, + params: t.type({ + query: t.type({ + aggregationType: t.string, + }), + }), + }, + { + path: '/service-map', + element: <>, + params: t.type({ + query: t.type({ + maxNumNodes: t.string.pipe(toNumberRt as any), + }), + }), + }, + ], + }, + ], + }, + ] as const); + + let history = createMemoryHistory(); + const router = createRouter(routes); + + beforeEach(() => { + history = createMemoryHistory(); + }); + + describe('getParams', () => { + it('returns parameters for routes matching the path only', () => { + history.push('/services?rangeFrom=now-15m&rangeTo=now&transactionType=request'); + const topLevelParams = router.getParams('/', history.location); + + expect(topLevelParams).toEqual({ + path: {}, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + }, + }); + + history.push('/services?rangeFrom=now-15m&rangeTo=now&transactionType=request'); + + const inventoryParams = router.getParams('/services', history.location); + + expect(inventoryParams).toEqual({ + path: {}, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + transactionType: 'request', + }, + }); + + history.push('/traces?rangeFrom=now-15m&rangeTo=now&aggregationType=avg'); + + const topTracesParams = router.getParams('/traces', history.location); + + expect(topTracesParams).toEqual({ + path: {}, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + aggregationType: 'avg', + }, + }); + + history.push( + '/services/opbeans-java?rangeFrom=now-15m&rangeTo=now&environment=production&transactionType=request' + ); + + const serviceOverviewParams = router.getParams('/services/:serviceName', history.location); + + expect(serviceOverviewParams).toEqual({ + path: { + serviceName: 'opbeans-java', + }, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + environment: 'production', + transactionType: 'request', + }, + }); + }); + + it('decodes the path and query parameters based on the route type', () => { + history.push('/service-map?rangeFrom=now-15m&rangeTo=now&maxNumNodes=3'); + const topServiceMapParams = router.getParams('/service-map', history.location); + + expect(topServiceMapParams).toEqual({ + path: {}, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + maxNumNodes: 3, + }, + }); + }); + + it('throws an error if the given path does not match any routes', () => { + expect(() => { + router.getParams('/service-map', history.location); + }).toThrowError('No matching route found for /service-map'); + }); + }); + + describe('matchRoutes', () => { + it('returns only the routes matching the path', () => { + history.push('/service-map?rangeFrom=now-15m&rangeTo=now&maxNumNodes=3'); + + expect(router.matchRoutes('/', history.location).length).toEqual(2); + expect(router.matchRoutes('/service-map', history.location).length).toEqual(3); + }); + + it('throws an error if the given path does not match any routes', () => { + history.push('/service-map?rangeFrom=now-15m&rangeTo=now&maxNumNodes=3'); + + expect(() => { + router.matchRoutes('/traces', history.location); + }).toThrowError('No matching route found for /traces'); + }); + }); + + describe('link', () => { + it('returns a link for the given route', () => { + const serviceOverviewLink = router.link('/services/:serviceName', { + path: { serviceName: 'opbeans-java' }, + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + environment: 'production', + transactionType: 'request', + }, + }); + + expect(serviceOverviewLink).toEqual( + '/services/opbeans-java?environment=production&rangeFrom=now-15m&rangeTo=now&transactionType=request' + ); + + const servicesLink = router.link('/services', { + query: { + rangeFrom: 'now-15m', + rangeTo: 'now', + transactionType: 'request', + }, + }); + + expect(servicesLink).toEqual( + '/services?rangeFrom=now-15m&rangeTo=now&transactionType=request' + ); + + const serviceMapLink = router.link('/service-map', { + query: { + maxNumNodes: '3', + rangeFrom: 'now-15m', + rangeTo: 'now', + }, + }); + + expect(serviceMapLink).toEqual('/service-map?maxNumNodes=3&rangeFrom=now-15m&rangeTo=now'); + }); + + it('validates the parameters needed for the route', () => { + expect(() => { + router.link('/traces', { + query: { + rangeFrom: {}, + }, + } as any); + }).toThrowError(); + + expect(() => { + router.link('/service-map', { + query: { + maxNumNodes: 3, + rangeFrom: 'now-15m', + rangeTo: 'now', + }, + } as any); + }).toThrowError(); + }); + }); +}); diff --git a/packages/kbn-typed-react-router-config/src/create_router.ts b/packages/kbn-typed-react-router-config/src/create_router.ts new file mode 100644 index 0000000000000..51b6e2a2f5692 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/create_router.ts @@ -0,0 +1,158 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { isLeft } from 'fp-ts/lib/Either'; +import { Location } from 'history'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { + matchRoutes as matchRoutesConfig, + RouteConfig as ReactRouterConfig, +} from 'react-router-config'; +import qs from 'query-string'; +import { findLastIndex, merge, compact } from 'lodash'; +import { deepExactRt } from '@kbn/io-ts-utils/target/deep_exact_rt'; +import { mergeRt } from '@kbn/io-ts-utils/target/merge_rt'; +import { Route, Router } from './types'; + +export function createRouter(routes: TRoutes): Router { + const routesByReactRouterConfig = new Map(); + const reactRouterConfigsByRoute = new Map(); + + const reactRouterConfigs = routes.map((route) => toReactRouterConfigRoute(route)); + + function toReactRouterConfigRoute(route: Route, prefix: string = ''): ReactRouterConfig { + const path = `${prefix}${route.path}`.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/'; + const reactRouterConfig: ReactRouterConfig = { + component: () => route.element, + routes: + (route.children as Route[] | undefined)?.map((child) => + toReactRouterConfigRoute(child, path) + ) ?? [], + exact: !route.children?.length, + path, + }; + + routesByReactRouterConfig.set(reactRouterConfig, route); + reactRouterConfigsByRoute.set(route, reactRouterConfig); + + return reactRouterConfig; + } + + const matchRoutes = (...args: any[]) => { + let path: string = args[0]; + let location: Location = args[1]; + + if (args.length === 1) { + location = args[0] as Location; + path = location.pathname; + } + + const greedy = path.endsWith('/*') || args.length === 1; + + if (!path) { + path = '/'; + } + + const matches = matchRoutesConfig(reactRouterConfigs, location.pathname); + + const matchIndex = greedy + ? matches.length - 1 + : findLastIndex(matches, (match) => match.route.path === path); + + if (matchIndex === -1) { + throw new Error(`No matching route found for ${path}`); + } + + return matches.slice(0, matchIndex + 1).map((matchedRoute) => { + const route = routesByReactRouterConfig.get(matchedRoute.route); + + if (route?.params) { + const decoded = deepExactRt(route.params).decode({ + path: matchedRoute.match.params, + query: qs.parse(location.search), + }); + + if (isLeft(decoded)) { + throw new Error(PathReporter.report(decoded).join('\n')); + } + + return { + match: { + ...matchedRoute.match, + params: decoded.right, + }, + route, + }; + } + + return { + match: { + ...matchedRoute.match, + params: { + path: {}, + query: {}, + }, + }, + route, + }; + }); + }; + + const link = (path: string, ...args: any[]) => { + const params: { path?: Record; query?: Record } | undefined = args[0]; + + const paramsWithDefaults = merge({ path: {}, query: {} }, params); + + path = path + .split('/') + .map((part) => { + return part.startsWith(':') ? paramsWithDefaults.path[part.split(':')[1]] : part; + }) + .join('/'); + + const matches = matchRoutesConfig(reactRouterConfigs, path); + + if (!matches.length) { + throw new Error(`No matching route found for ${path}`); + } + + const validationType = mergeRt( + ...(compact( + matches.map((match) => { + return routesByReactRouterConfig.get(match.route)?.params; + }) + ) as [any, any]) + ); + + const validation = validationType.decode(paramsWithDefaults); + + if (isLeft(validation)) { + throw new Error(PathReporter.report(validation).join('\n')); + } + + return qs.stringifyUrl({ + url: path, + query: paramsWithDefaults.query, + }); + }; + + return { + link: (path, ...args) => { + return link(path, ...args); + }, + getParams: (path, location) => { + const matches = matchRoutes(path, location); + return merge({ path: {}, query: {} }, ...matches.map((match) => match.match.params)); + }, + matchRoutes: (...args: any[]) => { + return matchRoutes(...args) as any; + }, + getRoutePath: (route) => { + return reactRouterConfigsByRoute.get(route)!.path as string; + }, + }; +} diff --git a/packages/kbn-typed-react-router-config/src/index.ts b/packages/kbn-typed-react-router-config/src/index.ts new file mode 100644 index 0000000000000..b58c70998901c --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/index.ts @@ -0,0 +1,19 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export * from './create_router'; +export * from './types'; +export * from './outlet'; +export * from './route'; +export * from './route_renderer'; +export * from './router_provider'; +export * from './unconst'; +export * from './use_current_route'; +export * from './use_match_routes'; +export * from './use_params'; +export * from './use_router'; +export * from './use_route_path'; diff --git a/packages/kbn-typed-react-router-config/src/outlet.tsx b/packages/kbn-typed-react-router-config/src/outlet.tsx new file mode 100644 index 0000000000000..696085489abee --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/outlet.tsx @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { useCurrentRoute } from './use_current_route'; + +export function Outlet() { + const { element } = useCurrentRoute(); + return element; +} diff --git a/packages/kbn-typed-react-router-config/src/route.ts b/packages/kbn-typed-react-router-config/src/route.ts new file mode 100644 index 0000000000000..b9b228d1009e2 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/route.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Route } from './types'; +import { Unconst, unconst } from './unconst'; + +export function route( + r: TRoute +): Unconst { + return unconst(r); +} diff --git a/packages/kbn-typed-react-router-config/src/route_renderer.tsx b/packages/kbn-typed-react-router-config/src/route_renderer.tsx new file mode 100644 index 0000000000000..e7a39aa7d5d16 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/route_renderer.tsx @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { CurrentRouteContextProvider } from './use_current_route'; +import { RouteMatch } from './types'; +import { useMatchRoutes } from './use_match_routes'; + +export function RouteRenderer() { + const matches: RouteMatch[] = useMatchRoutes(); + + return matches + .concat() + .reverse() + .reduce((prev, match) => { + const { element } = match.route; + return ( + + {element} + + ); + }, <>); +} diff --git a/packages/kbn-typed-react-router-config/src/router_provider.tsx b/packages/kbn-typed-react-router-config/src/router_provider.tsx new file mode 100644 index 0000000000000..d2512ba8fe426 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/router_provider.tsx @@ -0,0 +1,28 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { History } from 'history'; +import React from 'react'; +import { Router as ReactRouter } from 'react-router-dom'; +import { Route, Router } from './types'; +import { RouterContextProvider } from './use_router'; + +export function RouterProvider({ + children, + router, + history, +}: { + router: Router; + history: History; + children: React.ReactElement; +}) { + return ( + + {children} + + ); +} diff --git a/packages/kbn-typed-react-router-config/src/types/index.ts b/packages/kbn-typed-react-router-config/src/types/index.ts new file mode 100644 index 0000000000000..dfd9893966491 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -0,0 +1,421 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Location } from 'history'; +import * as t from 'io-ts'; +import { ReactElement } from 'react'; +import { RequiredKeys, ValuesType } from 'utility-types'; +// import { unconst } from '../unconst'; +import { NormalizePath } from './utils'; + +export type PathsOf = keyof MapRoutes & string; + +export interface RouteMatch { + route: TRoute; + match: { + isExact: boolean; + path: string; + url: string; + params: TRoute extends { + params: t.Type; + } + ? t.OutputOf + : {}; + }; +} + +type ToRouteMatch = TRoutes extends [] + ? [] + : TRoutes extends [Route] + ? [RouteMatch] + : TRoutes extends [Route, ...infer TTail] + ? TTail extends Route[] + ? [RouteMatch, ...ToRouteMatch] + : [] + : []; + +type UnwrapRouteMap = TRoute extends { + parents: Route[]; +} + ? ToRouteMatch<[...TRoute['parents'], Omit]> + : ToRouteMatch<[Omit]>; + +export type Match = MapRoutes extends { + [key in TPath]: Route; +} + ? UnwrapRouteMap[TPath]> + : []; + +interface PlainRoute { + path: string; + element: ReactElement; + children?: PlainRoute[]; + params?: t.Type; +} + +interface ReadonlyPlainRoute { + readonly path: string; + readonly element: ReactElement; + readonly children?: readonly ReadonlyPlainRoute[]; + readonly params?: t.Type; +} + +export type Route = PlainRoute | ReadonlyPlainRoute; + +interface DefaultOutput { + path: {}; + query: {}; +} + +type OutputOfRouteMatch = TRouteMatch extends { + route: { params: t.Type }; +} + ? t.OutputOf + : DefaultOutput; + +type OutputOfMatches = TRouteMatches extends [RouteMatch] + ? OutputOfRouteMatch + : TRouteMatches extends [RouteMatch, ...infer TNextRouteMatches] + ? OutputOfRouteMatch & + (TNextRouteMatches extends RouteMatch[] ? OutputOfMatches : DefaultOutput) + : TRouteMatches extends RouteMatch[] + ? OutputOfRouteMatch> + : DefaultOutput; + +export type OutputOf> = OutputOfMatches< + Match +> & + DefaultOutput; + +type TypeOfRouteMatch = TRouteMatch extends { + route: { params: t.Type }; +} + ? t.TypeOf + : {}; + +type TypeOfMatches = TRouteMatches extends [RouteMatch] + ? TypeOfRouteMatch + : TRouteMatches extends [RouteMatch, ...infer TNextRouteMatches] + ? TypeOfRouteMatch & + (TNextRouteMatches extends RouteMatch[] ? TypeOfMatches : {}) + : {}; + +export type TypeOf> = TypeOfMatches< + Match +>; + +export type TypeAsArgs = keyof TObject extends never + ? [] + : RequiredKeys extends never + ? [TObject] | [] + : [TObject]; + +export interface Router { + matchRoutes>( + path: TPath, + location: Location + ): Match; + matchRoutes(location: Location): Match>; + getParams>( + path: TPath, + location: Location + ): OutputOf; + link>( + path: TPath, + ...args: TypeAsArgs> + ): string; + getRoutePath(route: Route): string; +} + +type AppendPath< + TPrefix extends string, + TPath extends string +> = NormalizePath<`${TPrefix}${NormalizePath<`/${TPath}`>}`>; + +type MaybeUnion, U extends Record> = Omit & + { + [key in keyof U]: key extends keyof T ? T[key] | U[key] : U[key]; + }; + +type MapRoute< + TRoute extends Route, + TPrefix extends string, + TParents extends Route[] = [] +> = TRoute extends Route + ? MaybeUnion< + { + [key in AppendPath]: TRoute & { parents: TParents }; + }, + TRoute extends { children: Route[] } + ? MaybeUnion< + MapRoutes< + TRoute['children'], + AppendPath, + [...TParents, TRoute] + >, + { + [key in AppendPath>]: ValuesType< + MapRoutes< + TRoute['children'], + AppendPath, + [...TParents, TRoute] + > + >; + } + > + : {} + > + : {}; + +type MapRoutes< + TRoutes, + TPrefix extends string = '', + TParents extends Route[] = [] +> = TRoutes extends [Route] + ? MapRoute + : TRoutes extends [Route, Route] + ? MapRoute & MapRoute + : TRoutes extends [Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : TRoutes extends [Route, Route, Route, Route, Route, Route, Route, Route, Route, Route] + ? MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute & + MapRoute + : {}; + +// const element = null as any; + +// const routes = unconst([ +// { +// path: '/', +// element, +// children: [ +// { +// path: '/settings', +// element, +// children: [ +// { +// path: '/agent-configuration', +// element, +// }, +// { +// path: '/agent-configuration/create', +// element, +// params: t.partial({ +// query: t.partial({ +// pageStep: t.string, +// }), +// }), +// }, +// { +// path: '/agent-configuration/edit', +// element, +// params: t.partial({ +// query: t.partial({ +// pageStep: t.string, +// }), +// }), +// }, +// { +// path: '/apm-indices', +// element, +// }, +// { +// path: '/customize-ui', +// element, +// }, +// { +// path: '/schema', +// element, +// }, +// { +// path: '/anomaly-detection', +// element, +// }, +// { +// path: '/', +// element, +// }, +// ], +// }, +// { +// path: '/services/:serviceName', +// element, +// params: t.intersection([ +// t.type({ +// path: t.type({ +// serviceName: t.string, +// }), +// }), +// t.partial({ +// query: t.partial({ +// environment: t.string, +// rangeFrom: t.string, +// rangeTo: t.string, +// comparisonEnabled: t.string, +// comparisonType: t.string, +// latencyAggregationType: t.string, +// transactionType: t.string, +// kuery: t.string, +// }), +// }), +// ]), +// children: [ +// { +// path: '/overview', +// element, +// }, +// { +// path: '/transactions', +// element, +// }, +// { +// path: '/errors', +// element, +// children: [ +// { +// path: '/:groupId', +// element, +// params: t.type({ +// path: t.type({ +// groupId: t.string, +// }), +// }), +// }, +// { +// path: '/', +// element, +// params: t.partial({ +// query: t.partial({ +// sortDirection: t.string, +// sortField: t.string, +// pageSize: t.string, +// page: t.string, +// }), +// }), +// }, +// ], +// }, +// { +// path: '/foo', +// element, +// }, +// { +// path: '/bar', +// element, +// }, +// { +// path: '/baz', +// element, +// }, +// { +// path: '/', +// element, +// }, +// ], +// }, +// { +// path: '/', +// element, +// params: t.partial({ +// query: t.partial({ +// rangeFrom: t.string, +// rangeTo: t.string, +// }), +// }), +// children: [ +// { +// path: '/services', +// element, +// }, +// { +// path: '/traces', +// element, +// }, +// { +// path: '/service-map', +// element, +// }, +// { +// path: '/', +// element, +// }, +// ], +// }, +// ], +// }, +// ] as const); + +// type Routes = typeof routes; + +// type Mapped = keyof MapRoutes; + +// type Bar = ValuesType>['route']['path']; +// type Foo = OutputOf; + +// const { path }: Foo = {} as any; + +// function _useApmParams>(p: TPath): OutputOf { +// return {} as any; +// } + +// const params = _useApmParams('/*'); diff --git a/packages/kbn-typed-react-router-config/src/types/utils.ts b/packages/kbn-typed-react-router-config/src/types/utils.ts new file mode 100644 index 0000000000000..38b23c5118d0f --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/types/utils.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +export type MaybeOutputOf = T extends t.Type ? [t.OutputOf] : []; +export type NormalizePath = T extends `//${infer TRest}` + ? NormalizePath<`/${TRest}`> + : T extends '/' + ? T + : T extends `${infer TRest}/` + ? TRest + : T; +export type DeeplyMutableRoutes = T extends React.ReactElement + ? T + : T extends t.Type + ? T + : T extends readonly [infer U] + ? [DeeplyMutableRoutes] + : T extends readonly [infer U, ...infer V] + ? [DeeplyMutableRoutes, ...DeeplyMutableRoutes] + : T extends Record + ? { + -readonly [key in keyof T]: DeeplyMutableRoutes; + } + : T; diff --git a/packages/kbn-typed-react-router-config/src/unconst.ts b/packages/kbn-typed-react-router-config/src/unconst.ts new file mode 100644 index 0000000000000..d10c8290e20e9 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/unconst.ts @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import * as t from 'io-ts'; +import { DeepReadonly } from 'utility-types'; + +export type MaybeConst = TObject extends [object] + ? [DeepReadonly | TObject] + : TObject extends [object, ...infer TTail] + ? [DeepReadonly | TObject, ...(TTail extends object[] ? MaybeConst : [])] + : TObject extends object[] + ? DeepReadonly + : TObject extends object + ? [DeepReadonly | TObject] + : []; + +export type Unconst = T extends React.ReactElement + ? React.ReactElement + : T extends t.Type + ? T + : T extends readonly [any] + ? [Unconst] + : T extends readonly [any, any] + ? [Unconst, Unconst] + : T extends readonly [any, any, any] + ? [Unconst, Unconst, Unconst] + : T extends readonly [any, any, any, any] + ? [Unconst, Unconst, Unconst, Unconst] + : T extends readonly [any, any, any, any, any] + ? [Unconst, Unconst, Unconst, Unconst, Unconst] + : T extends readonly [any, any, any, any, any, any] + ? [Unconst, Unconst, Unconst, Unconst, Unconst, Unconst] + : T extends readonly [any, any, any, any, any, any, any] + ? [ + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst + ] + : T extends readonly [any, any, any, any, any, any, any, any] + ? [ + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst + ] + : T extends readonly [any, any, any, any, any, any, any, any, any] + ? [ + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst + ] + : T extends readonly [any, any, any, any, any, any, any, any, any, any] + ? [ + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst, + Unconst + ] + : T extends readonly [infer U, ...infer V] + ? [Unconst, ...Unconst] + : T extends Record + ? { -readonly [key in keyof T]: Unconst } + : T; + +export function unconst(value: T): Unconst { + return value as Unconst; +} diff --git a/packages/kbn-typed-react-router-config/src/use_current_route.tsx b/packages/kbn-typed-react-router-config/src/use_current_route.tsx new file mode 100644 index 0000000000000..9227b119107b3 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/use_current_route.tsx @@ -0,0 +1,37 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { createContext, useContext } from 'react'; +import { RouteMatch } from './types'; + +const CurrentRouteContext = createContext< + { match: RouteMatch; element: React.ReactElement } | undefined +>(undefined); + +export const CurrentRouteContextProvider = ({ + match, + element, + children, +}: { + match: RouteMatch; + element: React.ReactElement; + children: React.ReactElement; +}) => { + return ( + + {children} + + ); +}; + +export const useCurrentRoute = () => { + const currentRoute = useContext(CurrentRouteContext); + if (!currentRoute) { + throw new Error('No match was found in context'); + } + return currentRoute; +}; diff --git a/packages/kbn-typed-react-router-config/src/use_match_routes.ts b/packages/kbn-typed-react-router-config/src/use_match_routes.ts new file mode 100644 index 0000000000000..b818ff06e9ae6 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/use_match_routes.ts @@ -0,0 +1,20 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useLocation } from 'react-router-dom'; +import { RouteMatch } from './types'; +import { useRouter } from './use_router'; + +export function useMatchRoutes(path?: string): RouteMatch[] { + const router = useRouter(); + const location = useLocation(); + + return typeof path === 'undefined' + ? router.matchRoutes(location) + : router.matchRoutes(path as never, location); +} diff --git a/packages/kbn-typed-react-router-config/src/use_params.ts b/packages/kbn-typed-react-router-config/src/use_params.ts new file mode 100644 index 0000000000000..3f730e5d156f6 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/use_params.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useLocation } from 'react-router-dom'; +import { useRouter } from './use_router'; + +export function useParams(path: string) { + const router = useRouter(); + const location = useLocation(); + + return router.getParams(path as never, location); +} diff --git a/packages/kbn-typed-react-router-config/src/use_route_path.tsx b/packages/kbn-typed-react-router-config/src/use_route_path.tsx new file mode 100644 index 0000000000000..c77fc7d04b620 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/use_route_path.tsx @@ -0,0 +1,21 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { last } from 'lodash'; +import { useMatchRoutes } from './use_match_routes'; +import { useRouter } from './use_router'; + +export function useRoutePath() { + const lastRouteMatch = last(useMatchRoutes()); + const router = useRouter(); + if (!lastRouteMatch) { + throw new Error('No route was matched'); + } + + return router.getRoutePath(lastRouteMatch.route); +} diff --git a/packages/kbn-typed-react-router-config/src/use_router.tsx b/packages/kbn-typed-react-router-config/src/use_router.tsx new file mode 100644 index 0000000000000..b54530ed0fbdb --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/use_router.tsx @@ -0,0 +1,30 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; +import { Route, Router } from './types'; + +const RouterContext = createContext | undefined>(undefined); + +export const RouterContextProvider = ({ + router, + children, +}: { + router: Router; + children: React.ReactElement; +}) => {children}; + +export function useRouter(): Router { + const router = useContext(RouterContext); + + if (!router) { + throw new Error('Router not found in context'); + } + + return router; +} diff --git a/packages/kbn-typed-react-router-config/tsconfig.browser.json b/packages/kbn-typed-react-router-config/tsconfig.browser.json new file mode 100644 index 0000000000000..1de1603fec286 --- /dev/null +++ b/packages/kbn-typed-react-router-config/tsconfig.browser.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.browser.json", + "compilerOptions": { + "incremental": true, + "outDir": "./target_web", + "stripInternal": true, + "declaration": false, + "isolatedModules": true, + "sourceMap": true, + "sourceRoot": "../../../../../packages/kbn-typed-react-router-config/src", + "types": [ + "node", + "jest" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-typed-react-router-config/tsconfig.json b/packages/kbn-typed-react-router-config/tsconfig.json new file mode 100644 index 0000000000000..fb7262aa68662 --- /dev/null +++ b/packages/kbn-typed-react-router-config/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": true, + "declarationDir": "./target_types", + "outDir": "./target_node", + "stripInternal": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true, + "sourceMap": true, + "sourceRoot": "../../../../../packages/kbn-typed-react-router-config/src", + "types": [ + "node", + "jest" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/x-pack/plugins/apm/common/agent_configuration/constants.ts b/x-pack/plugins/apm/common/agent_configuration/constants.ts new file mode 100644 index 0000000000000..6ca70606cece0 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/constants.ts @@ -0,0 +1,19 @@ +/* + * 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 * as t from 'io-ts'; + +export enum AgentConfigurationPageStep { + ChooseService = 'choose-service-step', + ChooseSettings = 'choose-settings-step', + Review = 'review-step', +} + +export const agentConfigurationPageStepRt = t.union([ + t.literal(AgentConfigurationPageStep.ChooseService), + t.literal(AgentConfigurationPageStep.ChooseSettings), + t.literal(AgentConfigurationPageStep.Review), +]); diff --git a/x-pack/plugins/apm/dev_docs/routing_and_linking.md b/x-pack/plugins/apm/dev_docs/routing_and_linking.md index d27513d44935f..7c5a00f43fe4b 100644 --- a/x-pack/plugins/apm/dev_docs/routing_and_linking.md +++ b/x-pack/plugins/apm/dev_docs/routing_and_linking.md @@ -12,18 +12,47 @@ The path and query string parameters are defined in the calls to `createRoute` w ### Client-side -The client-side routing uses [React Router](https://reactrouter.com/), The [`ApmRoute` component from the Elastic RUM Agent](https://www.elastic.co/guide/en/apm/agent/rum-js/current/react-integration.html), and the `history` object provided by the Kibana Platform. +The client-side routing uses `@kbn/typed-react-router-config`, which is a wrapper around [React Router](https://reactrouter.com/) and [React Router Config](https://www.npmjs.com/package/react-router-config). Its goal is to provide a layer of high-fidelity types that allows us to parse and format URLs for routes while making sure the needed parameters are provided and/or available (typed and validated at runtime). The `history` object used by React Router is injected by the Kibana Platform. -Routes are defined in [public/components/app/Main/route_config/index.tsx](../public/components/app/Main/route_config/index.tsx). These contain route definitions as well as the breadcrumb text. +Routes (and their parameters) are defined in [public/components/routing/apm_config.tsx](../public/components/routing/apm_config.tsx). #### Parameter handling -Path parameters (like `serviceName` in '/services/:serviceName/transactions') are handled by the `match.params` props passed into -routes by React Router. The types of these parameters are defined in the route definitions. - -If the parameters are not available as props you can use React Router's `useParams`, but their type definitions should be delcared inline and it's a good idea to make the properties optional if you don't know where a component will be used, since those parameters might not be available at that route. - -Query string parameters can be used in any component with `useUrlParams`. All of the available parameters are defined by this hook and its context. +Path (like `serviceName` in '/services/:serviceName/transactions') and query parameters are defined in the route definitions. + +For each parameter, an io-ts runtime type needs to be present: + +```tsx +{ + route: '/services/:serviceName', + element: , + params: t.intersection([ + t.type({ + path: t.type({ + serviceName: t.string, + }) + }), + t.partial({ + query: t.partial({ + transactionType: t.string + }) + }) + ]) +} +``` + +To be able to use the parameters, you can use `useApmParams`, which will automatically infer the parameter types from the route path: + +```ts +const { + path: { serviceName }, // string + query: { transactionType } // string | undefined +} = useApmParams('/services/:serviceName'); +``` + +`useApmParams` will strip query parameters for which there is no validation. The route path should match exactly, but you can also use wildcards: `useApmParams('/*)`. In that case, the return type will be a union type of all possible matching routes. + +Previously we used `useUrlParams` for path and query parameters, which we are trying to get away from. When possible, any usage of `useUrlParams` should be replaced by `useApmParams` or other custom hooks that use `useApmParams` internally. ## Linking @@ -31,7 +60,16 @@ Raw URLs should almost never be used in the APM UI. Instead, we have mechanisms ### In-app linking -Links that stay inside APM should use the [`getAPMHref` function and `APMLink` component](../public/components/shared/Links/apm/APMLink.tsx). Other components inside that directory contain other functions and components that provide the same functionality for linking to more specific sections inside the APM plugin. +For links that stay inside APM, the preferred way of linking is to call the `useApmRouter` hook, and call `router.link` with the route path and required path and query parameters: + +```ts +const apmRouter = useApmRouter(); +const serviceOverviewLink = apmRouter.link('/services/:serviceName', { path: { serviceName: 'opbeans-java' }, query: { transactionType: 'request' }}); +``` + + If you're not in React context, you can also import `apmRouter` directly and call its `link` function - but you have to prepend the basePath manually in that case. + +We also have the [`getAPMHref` function and `APMLink` component](../public/components/shared/Links/apm/APMLink.tsx), but we should consider them deprecated, in favor of `router.link`. Other components inside that directory contain other functions and components that provide the same functionality for linking to more specific sections inside the APM plugin. ### Cross-app linking diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index eef3271d5932d..70da0be7ebae5 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -14,7 +14,7 @@ import { import { getInitialAlertValues } from '../get_initial_alert_values'; import { ApmPluginStartDeps } from '../../../plugin'; import { useServiceName } from '../../../hooks/use_service_name'; -import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; + interface Props { addFlyoutVisible: boolean; setAddFlyoutVisibility: React.Dispatch>; @@ -44,11 +44,5 @@ export function AlertingFlyout(props: Props) { /* eslint-disable-next-line react-hooks/exhaustive-deps */ [alertType, onCloseAddFlyout, services.triggersActionsUi] ); - return ( - <> - {addFlyoutVisible && ( - {addAlertFlyout} - )} - - ); + return <>{addFlyoutVisible && addAlertFlyout}; } diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index 811353067ab60..4ed3b9eeb19ff 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -18,7 +18,7 @@ import { ChartPreview } from '../chart_preview'; import { EnvironmentField, IsAboveField, ServiceField } from '../fields'; import { getAbsoluteTimeRange } from '../helper'; import { ServiceAlertTrigger } from '../service_alert_trigger'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useServiceName } from '../../../hooks/use_service_name'; export interface AlertParams { windowSize: number; @@ -37,12 +37,12 @@ interface Props { export function ErrorCountAlertTrigger(props: Props) { const { setAlertParams, setAlertProperty, alertParams } = props; - const { serviceName: serviceNameFromContext } = useApmServiceContext(); + const serviceNameFromUrl = useServiceName(); const { urlParams } = useUrlParams(); const { start, end, environment: environmentFromUrl } = urlParams; const { environmentOptions } = useEnvironmentsFetcher({ - serviceName: serviceNameFromContext, + serviceName: serviceNameFromUrl, start, end, }); @@ -56,7 +56,7 @@ export function ErrorCountAlertTrigger(props: Props) { windowSize: 1, windowUnit: 'm', environment: environmentFromUrl || ENVIRONMENT_ALL.value, - serviceName: serviceNameFromContext, + serviceName: serviceNameFromUrl, } ); diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 8f2713685127e..fa5f394d1747e 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -12,10 +12,13 @@ import React from 'react'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { getTransactionType } from '../../../context/apm_service/apm_service_context'; +import { useServiceAgentNameFetcher } from '../../../context/apm_service/use_service_agent_name_fetcher'; +import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; +import { useServiceName } from '../../../hooks/use_service_name'; import { getMaxY, getResponseTimeTickFormatter, @@ -74,11 +77,18 @@ export function TransactionDurationAlertTrigger(props: Props) { const { start, end, environment: environmentFromUrl } = urlParams; - const { + const serviceNameFromUrl = useServiceName(); + + const transactionTypes = useServiceTransactionTypesFetcher( + serviceNameFromUrl + ); + const { agentName } = useServiceAgentNameFetcher(serviceNameFromUrl); + + const transactionTypeFromUrl = getTransactionType({ + transactionType: urlParams.transactionType, transactionTypes, - transactionType: transactionTypeFromContext, - serviceName: serviceNameFromContext, - } = useApmServiceContext(); + agentName, + }); const params = defaults( { @@ -90,8 +100,8 @@ export function TransactionDurationAlertTrigger(props: Props) { threshold: 1500, windowSize: 5, windowUnit: 'm', - transactionType: transactionTypeFromContext, - serviceName: serviceNameFromContext, + transactionType: transactionTypeFromUrl, + serviceName: serviceNameFromUrl, } ); diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx index 11307b1cd5ae3..bdf20bc14ad90 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx @@ -23,7 +23,10 @@ import { ServiceField, TransactionTypeField, } from '../fields'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useServiceName } from '../../../hooks/use_service_name'; +import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher'; +import { useServiceAgentNameFetcher } from '../../../context/apm_service/use_service_agent_name_fetcher'; +import { getTransactionType } from '../../../context/apm_service/apm_service_context'; interface AlertParams { windowSize: number; @@ -47,11 +50,19 @@ interface Props { export function TransactionDurationAnomalyAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { - serviceName: serviceNameFromContext, - transactionType: transactionTypeFromContext, + + const serviceNameFromUrl = useServiceName(); + + const transactionTypes = useServiceTransactionTypesFetcher( + serviceNameFromUrl + ); + const { agentName } = useServiceAgentNameFetcher(serviceNameFromUrl); + + const transactionTypeFromUrl = getTransactionType({ + transactionType: urlParams.transactionType, transactionTypes, - } = useApmServiceContext(); + agentName, + }); const { start, end, environment: environmentFromUrl } = urlParams; @@ -62,10 +73,10 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { { windowSize: 15, windowUnit: 'm', - transactionType: transactionTypeFromContext, + transactionType: transactionTypeFromUrl, environment: environmentFromUrl || ENVIRONMENT_ALL.value, anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, - serviceName: serviceNameFromContext, + serviceName: serviceNameFromUrl, } ); diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index 4eb0b0e797571..cb519996f9496 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -10,7 +10,6 @@ import { defaults } from 'lodash'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { asPercent } from '../../../../common/utils/formatters'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; @@ -23,6 +22,10 @@ import { } from '../fields'; import { getAbsoluteTimeRange } from '../helper'; import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { useServiceName } from '../../../hooks/use_service_name'; +import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher'; +import { useServiceAgentNameFetcher } from '../../../context/apm_service/use_service_agent_name_fetcher'; +import { getTransactionType } from '../../../context/apm_service/apm_service_context'; interface AlertParams { windowSize: number; @@ -42,28 +45,36 @@ interface Props { export function TransactionErrorRateAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { - transactionType: transactionTypeFromContext, + + const serviceNameFromUrl = useServiceName(); + + const transactionTypes = useServiceTransactionTypesFetcher( + serviceNameFromUrl + ); + const { agentName } = useServiceAgentNameFetcher(serviceNameFromUrl); + + const transactionTypeFromUrl = getTransactionType({ + transactionType: urlParams.transactionType, transactionTypes, - serviceName: serviceNameFromContext, - } = useApmServiceContext(); + agentName, + }); const { start, end, environment: environmentFromUrl } = urlParams; - const params = defaults, AlertParams>( + const params = defaults( + { ...alertParams }, { threshold: 30, windowSize: 5, windowUnit: 'm', - transactionType: transactionTypeFromContext, + transactionType: transactionTypeFromUrl, environment: environmentFromUrl || ENVIRONMENT_ALL.value, - serviceName: serviceNameFromContext, - }, - alertParams + serviceName: serviceNameFromUrl, + } ); const { environmentOptions } = useEnvironmentsFetcher({ - serviceName: serviceNameFromContext, + serviceName: params.serviceName, start, end, }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx index 93ae8b270b5de..51a025df88d8e 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx @@ -16,16 +16,12 @@ import { import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React, { useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useApmRouter } from '../../../../../hooks/use_apm_router'; import { APIReturnType } from '../../../../../services/rest/createCallApmApi'; import { getOptionLabel } from '../../../../../../common/agent_configuration/all_option'; import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; import { useTheme } from '../../../../../hooks/use_theme'; -import { - createAgentConfigurationHref, - editAgentConfigurationHref, -} from '../../../../shared/Links/apm/agentConfigurationLinks'; import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; import { ITableColumn, ManagedTable } from '../../../../shared/managed_table'; import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; @@ -46,13 +42,17 @@ export function AgentConfigurationList({ }: Props) { const { core } = useApmPluginContext(); const canSave = core.application.capabilities.apm.save; - const { basePath } = core.http; - const { search } = useLocation(); const theme = useTheme(); const [configToBeDeleted, setConfigToBeDeleted] = useState( null ); + const apmRouter = useApmRouter(); + + const createAgentConfigurationHref = apmRouter.link( + '/settings/agent-configuration/create' + ); + const emptyStatePrompt = ( {i18n.translate( @@ -159,7 +159,12 @@ export function AgentConfigurationList({ flush="left" size="s" color="primary" - href={editAgentConfigurationHref(config.service, search, basePath)} + href={apmRouter.link('/settings/agent-configuration/edit', { + query: { + name: config.service.name, + environment: config.service.environment, + }, + })} > {getOptionLabel(config.service.name)} @@ -195,11 +200,12 @@ export function AgentConfigurationList({ ), }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/index.tsx index 32c93e43175df..f7cfc56bf4eac 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/index.tsx @@ -17,10 +17,9 @@ import { import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React from 'react'; -import { useLocation } from 'react-router-dom'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useFetcher } from '../../../../hooks/use_fetcher'; -import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; import { AgentConfigurationList } from './List'; const INITIAL_DATA = { configurations: [] }; @@ -72,10 +71,10 @@ export function AgentConfigurations() { } function CreateConfigurationButton() { + const href = useApmRouter().link('/settings/agent-configuration/create'); + const { core } = useApmPluginContext(); - const { basePath } = core.http; - const { search } = useLocation(); - const href = createAgentConfigurationHref(search, basePath); + const canSave = core.application.capabilities.apm.save; return ( diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx index d0c2b5c598039..36ebb239fd7dd 100644 --- a/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx @@ -7,22 +7,23 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { getRedirectToTransactionDetailPageUrl } from './get_redirect_to_transaction_detail_page_url'; import { getRedirectToTracePageUrl } from './get_redirect_to_trace_page_url'; +import { useApmParams } from '../../../hooks/use_apm_params'; const CentralizedContainer = euiStyled.div` height: 100%; display: flex; `; -export function TraceLink({ match }: RouteComponentProps<{ traceId: string }>) { - const { traceId } = match.params; - const { urlParams } = useUrlParams(); - const { rangeFrom, rangeTo } = urlParams; +export function TraceLink() { + const { + path: { traceId }, + query: { rangeFrom, rangeTo }, + } = useApmParams('/link-to/trace/:traceId'); const { data = { transaction: null }, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx index c78e9fc0a107a..0661b5ddc871d 100644 --- a/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx @@ -8,7 +8,7 @@ import { act, render, waitFor } from '@testing-library/react'; import { shallow } from 'enzyme'; import React, { ReactNode } from 'react'; -import { MemoryRouter, RouteComponentProps } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { TraceLink } from './'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { @@ -17,6 +17,7 @@ import { } from '../../../context/apm_plugin/mock_apm_plugin_context'; import * as hooks from '../../../hooks/use_fetcher'; import * as urlParamsHooks from '../../../context/url_params_context/use_url_params'; +import * as useApmParamsHooks from '../../../hooks/use_apm_params'; function Wrapper({ children }: { children?: ReactNode }) { return ( @@ -46,12 +47,19 @@ describe('TraceLink', () => { }); it('renders a transition page', async () => { - const props = ({ - match: { params: { traceId: 'x' } }, - } as unknown) as RouteComponentProps<{ traceId: string }>; + jest.spyOn(useApmParamsHooks as any, 'useApmParams').mockReturnValue({ + path: { + traceId: 'x', + }, + query: { + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }); + let result; act(() => { - const component = render(, renderOptions); + const component = render(, renderOptions); result = component.getByText('Fetching trace...'); }); @@ -76,10 +84,17 @@ describe('TraceLink', () => { refetch: jest.fn(), }); - const props = ({ - match: { params: { traceId: '123' } }, - } as unknown) as RouteComponentProps<{ traceId: string }>; - const component = shallow(); + jest.spyOn(useApmParamsHooks as any, 'useApmParams').mockReturnValue({ + path: { + traceId: '123', + }, + query: { + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }); + + const component = shallow(); expect(component.prop('to')).toEqual( '/traces?kuery=trace.id%2520%253A%2520%2522123%2522&rangeFrom=now-24h&rangeTo=now' @@ -93,10 +108,7 @@ describe('TraceLink', () => { rangeId: 0, refreshTimeRange: jest.fn(), uxUiFilters: {}, - urlParams: { - rangeFrom: 'now-24h', - rangeTo: 'now', - }, + urlParams: {}, }); }); @@ -116,10 +128,17 @@ describe('TraceLink', () => { refetch: jest.fn(), }); - const props = ({ - match: { params: { traceId: '123' } }, - } as unknown) as RouteComponentProps<{ traceId: string }>; - const component = shallow(); + jest.spyOn(useApmParamsHooks as any, 'useApmParams').mockReturnValue({ + path: { + traceId: '123', + }, + query: { + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }); + + const component = shallow(); expect(component.prop('to')).toEqual( '/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now' diff --git a/x-pack/plugins/apm/public/components/app/breadcrumb/index.tsx b/x-pack/plugins/apm/public/components/app/breadcrumb/index.tsx new file mode 100644 index 0000000000000..5cc1293d39f7d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/breadcrumb/index.tsx @@ -0,0 +1,24 @@ +/* + * 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 React from 'react'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; + +export const Breadcrumb = ({ + title, + href, + children, +}: { + title: string; + href: string; + children: React.ReactElement; +}) => { + const { core } = useApmPluginContext(); + useBreadcrumb({ title, href: core.http.basePath.prepend('/app/apm' + href) }); + + return children; +}; diff --git a/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx index f0d7f8d60eb6c..7202231fa66b2 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx @@ -18,8 +18,8 @@ import { import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; import { useUiTracker } from '../../../../../observability/public'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; @@ -51,7 +51,7 @@ export function ErrorCorrelations({ onClose }: Props) { setSelectedSignificantTerm, ] = useState(null); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { serviceName } = useApmServiceContext(); const { urlParams } = useUrlParams(); const { environment, diff --git a/x-pack/plugins/apm/public/components/app/correlations/index.tsx b/x-pack/plugins/apm/public/components/app/correlations/index.tsx index 9ad5088bb0bcf..31232e818cd9f 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/index.tsx @@ -23,7 +23,7 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { MlLatencyCorrelations } from './ml_latency_correlations'; import { ErrorCorrelations } from './error_correlations'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -49,6 +49,7 @@ import { SERVICE_NAME, TRANSACTION_NAME, } from '../../../../common/elasticsearch_fieldnames'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; const errorRateTab = { key: 'errorRate', @@ -70,7 +71,7 @@ export function Correlations() { const license = useLicenseContext(); const hasActivePlatinumLicense = isActivePlatinumLicense(license); const { urlParams } = useUrlParams(); - const { serviceName } = useParams<{ serviceName: string }>(); + const { serviceName } = useApmServiceContext(); const history = useHistory(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index e65bad8088c17..400a9f227959f 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -14,7 +14,6 @@ import { Settings, } from '@elastic/charts'; import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getDurationFormatter } from '../../../../common/utils/formatters'; @@ -31,6 +30,7 @@ import { CustomFields, PercentileOption } from './custom_fields'; import { useFieldNames } from './use_field_names'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { useUiTracker } from '../../../../../observability/public'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; type OverallLatencyApiResponse = NonNullable< APIReturnType<'GET /api/apm/correlations/latency/overall_distribution'> @@ -50,7 +50,7 @@ export function LatencyCorrelations({ onClose }: Props) { setSelectedSignificantTerm, ] = useState(null); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { serviceName } = useApmServiceContext(); const { urlParams } = useUrlParams(); const { environment, diff --git a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx index 03fab3e788639..319f8f70414dd 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/ml_latency_correlations.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { EuiIcon, EuiBasicTableColumn, @@ -36,6 +36,7 @@ import { useCorrelations } from './use_correlations'; import { push } from '../../shared/Links/url_helpers'; import { useUiTracker } from '../../../../../observability/public'; import { asPreciseDecimal } from '../../../../common/utils/formatters'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { LatencyCorrelationsHelpPopover } from './ml_latency_correlations_help_popover'; const DEFAULT_PERCENTILE_THRESHOLD = 95; @@ -60,17 +61,10 @@ export function MlLatencyCorrelations({ onClose }: Props) { core: { notifications }, } = useApmPluginContext(); - const { serviceName } = useParams<{ serviceName: string }>(); - const { - urlParams: { - environment, - kuery, - transactionName, - transactionType, - start, - end, - }, - } = useUrlParams(); + const { serviceName, transactionType } = useApmServiceContext(); + const { urlParams } = useUrlParams(); + + const { environment, kuery, transactionName, start, end } = urlParams; const { error, diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx index 225186cab12c8..3b7dea1e64060 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx @@ -18,7 +18,11 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; import { DetailView } from './detail_view'; @@ -88,17 +92,27 @@ function ErrorGroupHeader({ ); } -interface ErrorGroupDetailsProps { - groupId: string; - serviceName: string; -} - -export function ErrorGroupDetails({ - serviceName, - groupId, -}: ErrorGroupDetailsProps) { +export function ErrorGroupDetails() { const { urlParams } = useUrlParams(); const { environment, kuery, start, end } = urlParams; + const { serviceName } = useApmServiceContext(); + + const apmRouter = useApmRouter(); + + const { + path: { groupId }, + } = useApmParams('/services/:serviceName/errors/:groupId'); + + useBreadcrumb({ + title: groupId, + href: apmRouter.link('/services/:serviceName/errors/:groupId', { + path: { + serviceName, + groupId, + }, + }), + }); + const { data: errorGroupData } = useFetcher( (callApmApi) => { if (start && end) { diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 8d8d0cb9c107c..910f6aba3303d 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -14,20 +14,25 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; import { ErrorDistribution } from '../error_group_details/Distribution'; import { ErrorGroupList } from './List'; -interface ErrorGroupOverviewProps { - serviceName: string; -} +export function ErrorGroupOverview() { + const { serviceName } = useApmServiceContext(); + + const { + query: { environment, kuery, sortField, sortDirection }, + } = useApmParams('/services/:serviceName/errors'); -export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { const { - urlParams: { environment, kuery, start, end, sortField, sortDirection }, + urlParams: { start, end }, } = useUrlParams(); + const { errorDistributionData } = useErrorGroupDistributionFetcher({ serviceName, groupId: undefined, diff --git a/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx index 092ee2dd967ae..5d30c777cd595 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx @@ -8,6 +8,7 @@ import { act, waitFor } from '@testing-library/react'; import cytoscape from 'cytoscape'; import React, { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { renderWithTheme } from '../../../utils/testHelpers'; import { CytoscapeContext } from './Cytoscape'; @@ -17,11 +18,13 @@ const cy = cytoscape({}); function wrapper({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_map/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx index 22adb10512d7a..b10b527de25dd 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -11,7 +11,7 @@ import { EuiLoadingSpinner, EuiPanel, } from '@elastic/eui'; -import React, { PropsWithChildren, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { isActivePlatinumLicense } from '../../../../common/license_check'; import { invalidLicenseMessage, @@ -31,10 +31,7 @@ import { Popover } from './Popover'; import { TimeoutPrompt } from './timeout_prompt'; import { useRefDimensions } from './useRefDimensions'; import { SearchBar } from '../../shared/search_bar'; - -interface ServiceMapProps { - serviceName?: string; -} +import { useServiceName } from '../../../hooks/use_service_name'; function PromptContainer({ children }: { children: ReactNode }) { return ( @@ -66,13 +63,13 @@ function LoadingSpinner() { ); } -export function ServiceMap({ - serviceName, -}: PropsWithChildren) { +export function ServiceMap() { const theme = useTheme(); const license = useLicenseContext(); const { urlParams } = useUrlParams(); + const serviceName = useServiceName(); + const { data = { elements: [] }, status, error } = useFetcher( (callApmApi) => { // When we don't have a license or a valid license, don't make the request. diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx index 8711366fdd185..bff224cdc791c 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx @@ -16,10 +16,7 @@ describe('ServiceNodeMetrics', () => { expect(() => shallow( - + ) ).not.toThrowError(); diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 07afcbc9c4682..1b5754ef74e8b 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -19,10 +19,16 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { + getServiceNodeName, + SERVICE_NODE_NAME_MISSING, +} from '../../../../common/service_nodes'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric_charts_fetcher'; import { truncate, unit } from '../../../utils/style'; @@ -39,19 +45,33 @@ const Truncate = euiStyled.span` ${truncate(unit * 12)} `; -interface ServiceNodeMetricsProps { - serviceName: string; - serviceNodeName: string; -} - -export function ServiceNodeMetrics({ - serviceName, - serviceNodeName, -}: ServiceNodeMetricsProps) { +export function ServiceNodeMetrics() { const { urlParams: { kuery, start, end }, } = useUrlParams(); - const { agentName } = useApmServiceContext(); + const { agentName, serviceName } = useApmServiceContext(); + + const apmRouter = useApmRouter(); + + const { + path: { serviceNodeName }, + query, + } = useApmParams('/services/:serviceName/nodes/:serviceNodeName/metrics'); + + useBreadcrumb({ + title: getServiceNodeName(serviceNodeName), + href: apmRouter.link( + '/services/:serviceName/nodes/:serviceNodeName/metrics', + { + path: { + serviceName, + serviceNodeName, + }, + query, + } + ), + }); + const { data } = useServiceMetricChartsFetcher({ serviceNodeName }); const { data: { host, containerId } = INITIAL_DATA, status } = useFetcher( diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 58541e2c5501b..6431a8ad6d01c 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -17,6 +17,7 @@ import { asInteger, asPercent, } from '../../../../common/utils/formatters'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { truncate, unit } from '../../../utils/style'; @@ -31,15 +32,13 @@ const ServiceNodeName = euiStyled.div` ${truncate(8 * unit)} `; -interface ServiceNodeOverviewProps { - serviceName: string; -} - -function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { +function ServiceNodeOverview() { const { urlParams: { kuery, start, end }, } = useUrlParams(); + const { serviceName } = useApmServiceContext(); + const { data } = useFetcher( (callApmApi) => { if (!start || !end) { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 374b2d59ea347..803e9c73e9925 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -27,12 +27,8 @@ import { ServiceOverviewTransactionsTable } from './service_overview_transaction */ export const chartHeight = 288; -interface ServiceOverviewProps { - serviceName: string; -} - -export function ServiceOverview({ serviceName }: ServiceOverviewProps) { - const { agentName } = useApmServiceContext(); +export function ServiceOverview() { + const { agentName, serviceName } = useApmServiceContext(); // The default EuiFlexGroup breaks at 768, but we want to break at 992, so we // observe the window width and set the flex directions of rows accordingly @@ -61,7 +57,7 @@ export function ServiceOverview({ serviceName }: ServiceOverviewProps) { - + diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 4d6c0be9ff818..19318553727cb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -8,13 +8,13 @@ import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from 'src/core/public'; +import { isEqual } from 'lodash'; import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import * as useAnnotationsHooks from '../../../context/annotations/use_annotations_context'; @@ -28,11 +28,27 @@ import { getCallApmApiSpy, getCreateCallApmApiSpy, } from '../../../services/rest/callApmApiSpy'; +import { fromQuery } from '../../shared/Links/url_helpers'; +import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; +import { uiSettingsServiceMock } from '../../../../../../../src/core/public/mocks'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, } as Partial); +const mockParams = { + rangeFrom: 'now-15m', + rangeTo: 'now', + latencyAggregationType: LatencyAggregationType.avg, +}; + +const location = { + pathname: '/services/test%20service%20name/overview', + search: fromQuery(mockParams), +}; + +const uiSettings = uiSettingsServiceMock.create().setup({} as any); + function Wrapper({ children }: { children?: ReactNode }) { const value = ({ ...mockApmPluginContextValue, @@ -46,16 +62,14 @@ function Wrapper({ children }: { children?: ReactNode }) { } as unknown) as ApmPluginContextValue; return ( - - + + - + {children} @@ -69,6 +83,7 @@ describe('ServiceOverview', () => { jest .spyOn(useApmServiceContextHooks, 'useApmServiceContext') .mockReturnValue({ + serviceName: 'test service name', agentName: 'java', transactionType: 'request', transactionTypes: ['request'], @@ -96,6 +111,35 @@ describe('ServiceOverview', () => { }, 'GET /api/apm/services/{serviceName}/dependencies': [], 'GET /api/apm/services/{serviceName}/service_overview_instances/main_statistics': [], + 'GET /api/apm/services/{serviceName}/transactions/charts/latency': { + currentPeriod: { + overallAvgDuration: null, + latencyTimeseries: [], + }, + previousPeriod: { + overallAvgDuration: null, + latencyTimeseries: [], + }, + }, + 'GET /api/apm/services/{serviceName}/throughput': { + currentPeriod: [], + previousPeriod: [], + }, + 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate': { + currentPeriod: { + transactionErrorRate: [], + noHits: true, + average: null, + }, + previousPeriod: { + transactionErrorRate: [], + noHits: true, + average: null, + }, + }, + 'GET /api/apm/services/{serviceName}/annotation/search': { + annotations: [], + }, }; /* eslint-enable @typescript-eslint/naming-convention */ @@ -118,16 +162,16 @@ describe('ServiceOverview', () => { status: FETCH_STATUS.SUCCESS, }); - const { findAllByText } = renderWithTheme( - , - { - wrapper: Wrapper, - } - ); + const { findAllByText } = renderWithTheme(, { + wrapper: Wrapper, + }); - await waitFor(() => - expect(callApmApiSpy).toHaveBeenCalledTimes(Object.keys(calls).length) - ); + await waitFor(() => { + const endpoints = callApmApiSpy.mock.calls.map( + (call) => call[0].endpoint + ); + return isEqual(endpoints.sort(), Object.keys(calls).sort()); + }); expect((await findAllByText('Latency')).length).toBeGreaterThan(0); }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index c8da31da1b5d8..1d6c538570f8f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -8,7 +8,6 @@ import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useParams } from 'react-router-dom'; import { asTransactionRate } from '../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -31,7 +30,7 @@ export function ServiceOverviewThroughputChart({ height?: number; }) { const theme = useTheme(); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { urlParams: { environment, @@ -42,7 +41,8 @@ export function ServiceOverviewThroughputChart({ comparisonType, }, } = useUrlParams(); - const { transactionType } = useApmServiceContext(); + + const { transactionType, serviceName } = useApmServiceContext(); const comparisonChartTheme = getComparisonChartTheme(theme); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index a25bb807bdc46..952bb339cf396 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -25,10 +25,6 @@ import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time import { ServiceOverviewTableContainer } from '../service_overview_table_container'; import { getColumns } from './get_columns'; -interface Props { - serviceName: string; -} - type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics'>; const INITIAL_STATE = { transactionGroups: [] as ApiResponse['transactionGroups'], @@ -45,7 +41,7 @@ const DEFAULT_SORT = { field: 'impact' as const, }; -export function ServiceOverviewTransactionsTable({ serviceName }: Props) { +export function ServiceOverviewTransactionsTable() { const [tableOptions, setTableOptions] = useState<{ pageIndex: number; sort: { @@ -60,7 +56,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { const { pageIndex, sort } = tableOptions; const { direction, field } = sort; - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); const { urlParams: { start, diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx index c6e1f575298c6..82195bc5b4d17 100644 --- a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -10,24 +10,24 @@ import { getValueTypeConfig, ProfilingValueType, } from '../../../../common/profiling'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; import { ServiceProfilingTimeline } from './service_profiling_timeline'; -interface ServiceProfilingProps { - serviceName: string; - environment?: string; -} - type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/profiling/timeline'>; const DEFAULT_DATA: ApiResponse = { profilingTimeline: [] }; -export function ServiceProfiling({ - serviceName, - environment, -}: ServiceProfilingProps) { +export function ServiceProfiling() { + const { serviceName } = useApmServiceContext(); + + const { + query: { environment }, + } = useApmParams('/services/:serviceName/profiling'); + const { urlParams: { kuery, start, end }, } = useUrlParams(); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx index f59b3ddab7c05..a2db4cc87a81b 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/Distribution/index.tsx @@ -25,7 +25,6 @@ import { isEmpty, keyBy } from 'lodash'; import React from 'react'; import { ValuesType } from 'utility-types'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; -import type { IUrlParams } from '../../../../context/url_params_context/types'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; @@ -94,7 +93,6 @@ export const formatYLong = (t: number) => { interface Props { distribution?: TransactionDistributionAPIResponse; - urlParams: IUrlParams; fetchStatus: FETCH_STATUS; bucketIndex: number; onBucketClick: ( @@ -104,7 +102,6 @@ interface Props { export function TransactionDistribution({ distribution, - urlParams: { transactionType }, fetchStatus, bucketIndex, onBucketClick, diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 40f50e768e76e..6f0639de93e43 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -9,8 +9,11 @@ import { EuiHorizontalRule, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { flatten, isEmpty } from 'lodash'; import React from 'react'; import { useHistory } from 'react-router-dom'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTransactionDistributionFetcher } from '../../../hooks/use_transaction_distribution_fetcher'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; @@ -28,17 +31,33 @@ interface Sample { export function TransactionDetails() { const { urlParams } = useUrlParams(); const history = useHistory(); - const { - distributionData, - distributionStatus, - } = useTransactionDistributionFetcher(); const { waterfall, exceedsMax, status: waterfallStatus, } = useWaterfallFetcher(); - const { transactionName } = urlParams; + + const { path, query } = useApmParams( + '/services/:serviceName/transactions/view' + ); + + const apmRouter = useApmRouter(); + + const { transactionName } = query; + + const { + distributionData, + distributionStatus, + } = useTransactionDistributionFetcher({ transactionName }); + + useBreadcrumb({ + title: transactionName, + href: apmRouter.link('/services/:serviceName/transactions/view', { + path, + query, + }), + }); const selectedSample = flatten( distributionData.buckets.map((bucket) => bucket.samples) @@ -90,7 +109,6 @@ export function TransactionDetails() { { if (!isEmpty(bucket.samples)) { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx index 817e747551d95..c352afbe03ff2 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { keyBy } from 'lodash'; -import { useParams } from 'react-router-dom'; import { IUrlParams } from '../../../../../context/url_params_context/types'; import { IWaterfall, @@ -15,6 +14,7 @@ import { } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { Waterfall } from './Waterfall'; import { WaterfallLegends } from './WaterfallLegends'; +import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context'; interface Props { urlParams: IUrlParams; @@ -27,7 +27,7 @@ export function WaterfallContainer({ waterfall, exceedsMax, }: Props) { - const { serviceName } = useParams<{ serviceName: string }>(); + const { serviceName } = useApmServiceContext(); if (!waterfall) { return null; diff --git a/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx index c6394f09b0d3c..25cbf2d319587 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_link/index.tsx @@ -7,23 +7,22 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { getRedirectToTransactionDetailPageUrl } from '../TraceLink/get_redirect_to_transaction_detail_page_url'; +import { useApmParams } from '../../../hooks/use_apm_params'; const CentralizedContainer = euiStyled.div` height: 100%; display: flex; `; -export function TransactionLink({ - match, -}: RouteComponentProps<{ transactionId: string }>) { - const { transactionId } = match.params; - const { urlParams } = useUrlParams(); - const { rangeFrom, rangeTo } = urlParams; +export function TransactionLink() { + const { + path: { transactionId }, + query: { rangeFrom, rangeTo }, + } = useApmParams('/link-to/transaction/:transactionId'); const { data = { transaction: null }, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 2435e5fc5a1f6..819292095403a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -49,14 +49,10 @@ function getRedirectLocation({ } } -interface TransactionOverviewProps { - serviceName: string; -} - -export function TransactionOverview({ serviceName }: TransactionOverviewProps) { +export function TransactionOverview() { const location = useLocation(); const { urlParams } = useUrlParams(); - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); // redirect to first transaction type useRedirect(getRedirectLocation({ location, transactionType, urlParams })); diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index 9c4c2aa11a858..afed0be7cc209 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -9,7 +9,6 @@ import { queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; -import { Router } from 'react-router-dom'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; @@ -63,14 +62,12 @@ function setup({ return renderWithTheme( - - - - - - - - + + + + + + ); diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts index 062fd5470e60c..59207a6a499a2 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; type TransactionsAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups'>; @@ -22,7 +22,8 @@ export function useTransactionListFetcher() { const { urlParams: { environment, kuery, transactionType, start, end }, } = useUrlParams(); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { serviceName } = useApmServiceContext(); + const { data = DEFAULT_RESPONSE, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType) { diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx index e00b7893b548e..099519416baf2 100644 --- a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -5,534 +5,68 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { createRouter, Outlet, route } from '@kbn/typed-react-router-config'; +import * as t from 'io-ts'; import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { getServiceNodeName } from '../../../common/service_nodes'; -import { APMRouteDefinition } from '../../application/routes'; -import { toQuery } from '../shared/Links/url_helpers'; -import { ErrorGroupDetails } from '../app/error_group_details'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -import { ServiceNodeMetrics } from '../app/service_node_metrics'; -import { SettingsTemplate } from './templates/settings_template'; -import { AgentConfigurations } from '../app/Settings/agent_configurations'; -import { AnomalyDetection } from '../app/Settings/anomaly_detection'; -import { ApmIndices } from '../app/Settings/ApmIndices'; -import { CustomizeUI } from '../app/Settings/customize_ui'; -import { Schema } from '../app/Settings/schema'; +import { Breadcrumb } from '../app/breadcrumb'; import { TraceLink } from '../app/TraceLink'; import { TransactionLink } from '../app/transaction_link'; -import { TransactionDetails } from '../app/transaction_details'; -import { enableServiceOverview } from '../../../common/ui_settings_keys'; -import { redirectTo } from './redirect_to'; -import { ApmMainTemplate } from './templates/apm_main_template'; -import { ApmServiceTemplate } from './templates/apm_service_template'; -import { ServiceProfiling } from '../app/service_profiling'; -import { ErrorGroupOverview } from '../app/error_group_overview'; -import { ServiceMap } from '../app/service_map'; -import { ServiceNodeOverview } from '../app/service_node_overview'; -import { ServiceMetrics } from '../app/service_metrics'; -import { ServiceOverview } from '../app/service_overview'; -import { TransactionOverview } from '../app/transaction_overview'; -import { ServiceInventory } from '../app/service_inventory'; -import { TraceOverview } from '../app/trace_overview'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { AgentConfigurationCreateEdit } from '../app/Settings/agent_configurations/AgentConfigurationCreateEdit'; - -// These component function definitions are used below with the `component` -// property of the route definitions. -// -// If you provide an inline function to the component prop, you would create a -// new component every render. This results in the existing component unmounting -// and the new component mounting instead of just updating the existing component. - -const ServiceInventoryTitle = i18n.translate( - 'xpack.apm.views.serviceInventory.title', - { defaultMessage: 'Services' } -); - -function ServiceInventoryView() { - return ( - - - - ); -} - -const TraceOverviewTitle = i18n.translate( - 'xpack.apm.views.traceOverview.title', - { - defaultMessage: 'Traces', - } -); - -function TraceOverviewView() { - return ( - - - - ); -} - -const ServiceMapTitle = i18n.translate('xpack.apm.views.serviceMap.title', { - defaultMessage: 'Service Map', -}); - -function ServiceMapView() { - return ( - - - - ); -} - -function ServiceDetailsErrorsRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ErrorGroupDetailsRouteView( - props: RouteComponentProps<{ serviceName: string; groupId: string }> -) { - const { serviceName, groupId } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsMetricsRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsNodesRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsOverviewRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsServiceMapRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsTransactionsRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceDetailsProfilingRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function ServiceNodeMetricsRouteView( - props: RouteComponentProps<{ - serviceName: string; - serviceNodeName: string; - }> -) { - const { serviceName, serviceNodeName } = props.match.params; - return ( - - - - ); -} - -function TransactionDetailsRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { serviceName } = props.match.params; - return ( - - - - ); -} - -function SettingsAgentConfigurationRouteView() { - return ( - - - - ); -} - -function SettingsAnomalyDetectionRouteView() { - return ( - - - - ); -} - -function SettingsApmIndicesRouteView() { - return ( - - - - ); -} - -function SettingsCustomizeUI() { - return ( - - - - ); -} - -function SettingsSchema() { - return ( - - - - ); -} - -export function EditAgentConfigurationRouteView(props: RouteComponentProps) { - const { search } = props.history.location; - - // typescript complains because `pageStop` does not exist in `APMQueryParams` - // Going forward we should move away from globally declared query params and this is a first step - // @ts-expect-error - const { name, environment, pageStep } = toQuery(search); - - const res = useFetcher( - (callApmApi) => { - return callApmApi({ - endpoint: 'GET /api/apm/settings/agent-configuration/view', - params: { query: { name, environment } }, - }); - }, - [name, environment] - ); - - return ( - - - - ); -} - -export function CreateAgentConfigurationRouteView(props: RouteComponentProps) { - const { search } = props.history.location; - - // Ignoring here because we specifically DO NOT want to add the query params to the global route handler - // @ts-expect-error - const { pageStep } = toQuery(search); - - return ( - - - - ); -} - -const SettingsApmIndicesTitle = i18n.translate( - 'xpack.apm.views.settings.indices.title', - { defaultMessage: 'Indices' } -); - -const SettingsAgentConfigurationTitle = i18n.translate( - 'xpack.apm.views.settings.agentConfiguration.title', - { defaultMessage: 'Agent Configuration' } -); -const CreateAgentConfigurationTitle = i18n.translate( - 'xpack.apm.views.settings.createAgentConfiguration.title', - { defaultMessage: 'Create Agent Configuration' } -); -const EditAgentConfigurationTitle = i18n.translate( - 'xpack.apm.views.settings.editAgentConfiguration.title', - { defaultMessage: 'Edit Agent Configuration' } -); -const SettingsCustomizeUITitle = i18n.translate( - 'xpack.apm.views.settings.customizeUI.title', - { defaultMessage: 'Customize app' } -); -const SettingsSchemaTitle = i18n.translate( - 'xpack.apm.views.settings.schema.title', - { defaultMessage: 'Schema' } -); -const SettingsAnomalyDetectionTitle = i18n.translate( - 'xpack.apm.views.settings.anomalyDetection.title', - { defaultMessage: 'Anomaly detection' } -); -const SettingsTitle = i18n.translate('xpack.apm.views.listSettings.title', { - defaultMessage: 'Settings', -}); +import { home } from './home'; +import { serviceDetail } from './service_detail'; +import { settings } from './settings'; /** * The array of route definitions to be used when the application * creates the routes. */ -export const apmRouteConfig: APMRouteDefinition[] = [ - /* - * Home routes - */ +const apmRoutes = route([ { - exact: true, path: '/', - render: redirectTo('/services'), - breadcrumb: 'APM', - }, - { - exact: true, - path: '/services', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - component: ServiceInventoryView, - breadcrumb: ServiceInventoryTitle, - }, - { - exact: true, - path: '/traces', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - component: TraceOverviewView, - breadcrumb: TraceOverviewTitle, - }, - { - exact: true, - path: '/service-map', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - component: ServiceMapView, - breadcrumb: ServiceMapTitle, - }, - - /* - * Settings routes - */ - { - exact: true, - path: '/settings', - render: redirectTo('/settings/agent-configuration'), - breadcrumb: SettingsTitle, + element: ( + + + + ), + children: [settings, serviceDetail, home], }, { - exact: true, - path: '/settings/agent-configuration', - component: SettingsAgentConfigurationRouteView, - breadcrumb: SettingsAgentConfigurationTitle, - }, - { - exact: true, - path: '/settings/agent-configuration/create', - component: CreateAgentConfigurationRouteView, - breadcrumb: CreateAgentConfigurationTitle, - }, - { - exact: true, - path: '/settings/agent-configuration/edit', - breadcrumb: EditAgentConfigurationTitle, - component: EditAgentConfigurationRouteView, - }, - { - exact: true, - path: '/settings/apm-indices', - component: SettingsApmIndicesRouteView, - breadcrumb: SettingsApmIndicesTitle, - }, - { - exact: true, - path: '/settings/customize-ui', - component: SettingsCustomizeUI, - breadcrumb: SettingsCustomizeUITitle, - }, - { - exact: true, - path: '/settings/schema', - component: SettingsSchema, - breadcrumb: SettingsSchemaTitle, - }, - { - exact: true, - path: '/settings/anomaly-detection', - component: SettingsAnomalyDetectionRouteView, - breadcrumb: SettingsAnomalyDetectionTitle, - }, - - /* - * Services routes (with APM Service context) - */ - { - exact: true, - path: '/services/:serviceName', - breadcrumb: ({ match }) => match.params.serviceName, - component: RedirectToDefaultServiceRouteView, - }, - { - exact: true, - path: '/services/:serviceName/overview', - breadcrumb: i18n.translate('xpack.apm.views.overview.title', { - defaultMessage: 'Overview', - }), - component: ServiceDetailsOverviewRouteView, - }, - { - exact: true, - path: '/services/:serviceName/transactions', - component: ServiceDetailsTransactionsRouteView, - breadcrumb: i18n.translate('xpack.apm.views.transactions.title', { - defaultMessage: 'Transactions', - }), - }, - { - exact: true, - path: '/services/:serviceName/errors/:groupId', - component: ErrorGroupDetailsRouteView, - breadcrumb: ({ match }) => match.params.groupId, - }, - { - exact: true, - path: '/services/:serviceName/errors', - component: ServiceDetailsErrorsRouteView, - breadcrumb: i18n.translate('xpack.apm.views.errors.title', { - defaultMessage: 'Errors', - }), - }, - { - exact: true, - path: '/services/:serviceName/metrics', - component: ServiceDetailsMetricsRouteView, - breadcrumb: i18n.translate('xpack.apm.views.metrics.title', { - defaultMessage: 'Metrics', - }), - }, - // service nodes, only enabled for java agents for now - { - exact: true, - path: '/services/:serviceName/nodes', - component: ServiceDetailsNodesRouteView, - breadcrumb: i18n.translate('xpack.apm.views.nodes.title', { - defaultMessage: 'JVMs', - }), - }, - // node metrics - { - exact: true, - path: '/services/:serviceName/nodes/:serviceNodeName/metrics', - component: ServiceNodeMetricsRouteView, - breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), - }, - { - exact: true, - path: '/services/:serviceName/transactions/view', - component: TransactionDetailsRouteView, - breadcrumb: ({ location }) => { - const query = toQuery(location.search); - return query.transactionName as string; - }, - }, - { - exact: true, - path: '/services/:serviceName/profiling', - component: ServiceDetailsProfilingRouteView, - breadcrumb: i18n.translate('xpack.apm.views.serviceProfiling.title', { - defaultMessage: 'Profiling', - }), - }, - { - exact: true, - path: '/services/:serviceName/service-map', - component: ServiceDetailsServiceMapRouteView, - breadcrumb: i18n.translate('xpack.apm.views.serviceMap.title', { - defaultMessage: 'Service Map', - }), + path: '/link-to/transaction/:transactionId', + element: , + params: t.intersection([ + t.type({ + path: t.type({ + transactionId: t.string, + }), + }), + t.partial({ + query: t.partial({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + ]), }, - /* - * Utilility routes - */ { - exact: true, path: '/link-to/trace/:traceId', - component: TraceLink, - breadcrumb: null, - }, - { - exact: true, - path: '/link-to/transaction/:transactionId', - component: TransactionLink, - breadcrumb: null, - }, -]; - -function RedirectToDefaultServiceRouteView( - props: RouteComponentProps<{ serviceName: string }> -) { - const { uiSettings } = useApmPluginContext().core; - const { serviceName } = props.match.params; - if (uiSettings.get(enableServiceOverview)) { - return redirectTo(`/services/${serviceName}/overview`)(props); - } - return redirectTo(`/services/${serviceName}/transactions`)(props); -} + element: , + params: t.intersection([ + t.type({ + path: t.type({ + traceId: t.string, + }), + }), + t.partial({ + query: t.partial({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + ]), + }, +] as const); + +export type ApmRoutes = typeof apmRoutes; + +export const apmRouter = createRouter(apmRoutes); + +export type ApmRouter = typeof apmRouter; diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx index a924c1f31cbef..e82897083ae02 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { ApmRoute } from '@elastic/apm-rum-react'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config'; import React from 'react'; -import { Route, RouteComponentProps, Router, Switch } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { DefaultTheme, ThemeProvider } from 'styled-components'; import { APP_WRAPPER_CLASS } from '../../../../../../src/core/public'; import { @@ -25,13 +25,13 @@ import { ApmPluginContextValue, } from '../../context/apm_plugin/apm_plugin_context'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { BreadcrumbsContextProvider } from '../../context/breadcrumbs/context'; import { LicenseProvider } from '../../context/license/license_context'; import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; -import { useApmBreadcrumbs } from '../../hooks/use_apm_breadcrumbs'; import { ApmPluginStartDeps } from '../../plugin'; import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; -import { apmRouteConfig } from './apm_route_config'; -import { TelemetryWrapper } from './telemetry_wrapper'; +import { apmRouter } from './apm_route_config'; +import { TrackPageview } from './track_pageview'; export function ApmAppRoot({ apmPluginContextValue, @@ -54,33 +54,24 @@ export function ApmAppRoot({ - - - - - - + + + + + + + + - - - {apmRouteConfig.map((route, i) => { - const { component, render, ...rest } = route; - return ( - { - return TelemetryWrapper({ route, props }); - }} - /> - ); - })} - - - - - - + + + + + + + + + @@ -89,7 +80,6 @@ export function ApmAppRoot({ } function MountApmHeaderActionMenu() { - useApmBreadcrumbs(apmRouteConfig); const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; return ( diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx new file mode 100644 index 0000000000000..454dcdedace90 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -0,0 +1,78 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Outlet } from '@kbn/typed-react-router-config'; +import * as t from 'io-ts'; +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { Breadcrumb } from '../../app/breadcrumb'; +import { ServiceInventory } from '../../app/service_inventory'; +import { ServiceMap } from '../../app/service_map'; +import { TraceOverview } from '../../app/trace_overview'; +import { ApmMainTemplate } from '../templates/apm_main_template'; + +function page({ + path, + element, + title, +}: { + path: TPath; + element: React.ReactElement; + title: string; +}): { path: TPath; element: React.ReactElement } { + return { + path, + element: ( + + {element} + + ), + }; +} + +export const ServiceInventoryTitle = i18n.translate( + 'xpack.apm.views.serviceInventory.title', + { + defaultMessage: 'Services', + } +); + +export const home = { + path: '/', + element: , + params: t.partial({ + query: t.partial({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + children: [ + page({ + path: '/services', + title: ServiceInventoryTitle, + element: , + }), + page({ + path: '/traces', + title: i18n.translate('xpack.apm.views.traceOverview.title', { + defaultMessage: 'Traces', + }), + element: , + }), + page({ + path: '/service-map', + title: i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', + }), + element: , + }), + { + path: '/', + element: , + }, + ], +} as const; diff --git a/x-pack/plugins/apm/public/components/routing/route_config.test.tsx b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx deleted file mode 100644 index b1d5c1a83b43b..0000000000000 --- a/x-pack/plugins/apm/public/components/routing/route_config.test.tsx +++ /dev/null @@ -1,41 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { apmRouteConfig } from './apm_route_config'; - -describe('routes', () => { - describe('/', () => { - const route = apmRouteConfig.find((r) => r.path === '/'); - - describe('with no hash path', () => { - it('redirects to /services', () => { - const location = { hash: '', pathname: '/', search: '' }; - expect( - (route!.render!({ location } as any) as any).props.to.pathname - ).toEqual('/services'); - }); - }); - - describe('with a hash path', () => { - it('redirects to the hash path', () => { - const location = { - hash: - '#/services/opbeans-python/transactions/view?rangeFrom=now-24h&rangeTo=now&refreshInterval=10000&refreshPaused=false&traceId=d919c89dc7ca48d84b9dde1fef01d1f8&transactionId=1b542853d787ba7b&transactionName=GET%20opbeans.views.product_customers&transactionType=request&flyoutDetailTab=&waterfallItemId=1b542853d787ba7b', - pathname: '', - search: '', - }; - - expect((route!.render!({ location } as any) as any).props.to).toEqual({ - hash: '', - pathname: '/services/opbeans-python/transactions/view', - search: - '?rangeFrom=now-24h&rangeTo=now&refreshInterval=10000&refreshPaused=false&traceId=d919c89dc7ca48d84b9dde1fef01d1f8&transactionId=1b542853d787ba7b&transactionName=GET%20opbeans.views.product_customers&transactionType=request&flyoutDetailTab=&waterfallItemId=1b542853d787ba7b', - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/apm_service_wrapper.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/apm_service_wrapper.tsx new file mode 100644 index 0000000000000..aa69aa4fa7965 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/service_detail/apm_service_wrapper.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +import { Outlet } from '@kbn/typed-react-router-config'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { ServiceInventoryTitle } from '../home'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; + +export function ApmServiceWrapper() { + const { + path: { serviceName }, + query, + } = useApmParams('/services/:serviceName'); + + const router = useApmRouter(); + + useBreadcrumb([ + { + title: ServiceInventoryTitle, + href: router.link('/services', { query }), + }, + { + title: serviceName, + href: router.link('/services/:serviceName', { + query, + path: { serviceName }, + }), + }, + ]); + + return ; +} diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx new file mode 100644 index 0000000000000..19db296c428c8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -0,0 +1,226 @@ +/* + * 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 * as t from 'io-ts'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { Outlet } from '@kbn/typed-react-router-config'; +import { ServiceOverview } from '../../app/service_overview'; +import { ApmServiceTemplate } from '../templates/apm_service_template'; +import { RedirectToDefaultServiceRouteView } from './redirect_to_default_service_route_view'; +import { TransactionOverview } from '../../app/transaction_overview'; +import { ApmServiceWrapper } from './apm_service_wrapper'; +import { ErrorGroupOverview } from '../../app/error_group_overview'; +import { ErrorGroupDetails } from '../../app/error_group_details'; +import { ServiceMetrics } from '../../app/service_metrics'; +import { ServiceNodeOverview } from '../../app/service_node_overview'; +import { ServiceNodeMetrics } from '../../app/service_node_metrics'; +import { ServiceMap } from '../../app/service_map'; +import { TransactionDetails } from '../../app/transaction_details'; +import { ServiceProfiling } from '../../app/service_profiling'; + +function page({ + path, + title, + tab, + element, + searchBarOptions, +}: { + path: TPath; + title: string; + tab: React.ComponentProps['selectedTab']; + element: React.ReactElement; + searchBarOptions?: { + showTransactionTypeSelector?: boolean; + showTimeComparison?: boolean; + hidden?: boolean; + }; +}): { + element: React.ReactElement; + path: TPath; +} { + return { + path, + element: ( + + {element} + + ), + } as any; +} + +export const serviceDetail = { + path: '/services/:serviceName', + element: , + params: t.intersection([ + t.type({ + path: t.type({ + serviceName: t.string, + }), + }), + t.partial({ + query: t.partial({ + environment: t.string, + rangeFrom: t.string, + rangeTo: t.string, + comparisonEnabled: t.string, + comparisonType: t.string, + latencyAggregationType: t.string, + transactionType: t.string, + kuery: t.string, + }), + }), + ]), + children: [ + page({ + path: '/overview', + element: , + tab: 'overview', + title: i18n.translate('xpack.apm.views.overview.title', { + defaultMessage: 'Overview', + }), + searchBarOptions: { + showTransactionTypeSelector: true, + showTimeComparison: true, + }, + }), + { + ...page({ + path: '/transactions', + tab: 'transactions', + title: i18n.translate('xpack.apm.views.transactions.title', { + defaultMessage: 'Transactions', + }), + element: , + searchBarOptions: { + showTransactionTypeSelector: true, + }, + }), + children: [ + { + path: '/view', + element: , + params: t.type({ + query: t.intersection([ + t.type({ + transactionName: t.string, + }), + t.partial({ + traceId: t.string, + transactionId: t.string, + }), + ]), + }), + }, + { + path: '/', + element: , + }, + ], + }, + { + ...page({ + path: '/errors', + tab: 'errors', + title: i18n.translate('xpack.apm.views.errors.title', { + defaultMessage: 'Errors', + }), + element: , + }), + params: t.partial({ + query: t.partial({ + sortDirection: t.string, + sortField: t.string, + pageSize: t.string, + page: t.string, + }), + }), + children: [ + { + path: '/:groupId', + element: , + params: t.type({ + path: t.type({ + groupId: t.string, + }), + }), + }, + { + path: '/', + element: , + }, + ], + }, + page({ + path: '/metrics', + tab: 'metrics', + title: i18n.translate('xpack.apm.views.metrics.title', { + defaultMessage: 'Metrics', + }), + element: , + }), + { + ...page({ + path: '/nodes', + tab: 'nodes', + title: i18n.translate('xpack.apm.views.nodes.title', { + defaultMessage: 'JVMs', + }), + element: , + }), + children: [ + { + path: '/:serviceNodeName/metrics', + element: , + params: t.type({ + path: t.type({ + serviceNodeName: t.string, + }), + }), + }, + { + path: '/', + element: , + params: t.partial({ + query: t.partial({ + sortDirection: t.string, + sortField: t.string, + pageSize: t.string, + page: t.string, + }), + }), + }, + ], + }, + page({ + path: '/service-map', + tab: 'service-map', + title: i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', + }), + element: , + searchBarOptions: { + hidden: true, + }, + }), + page({ + path: '/profiling', + tab: 'profiling', + title: i18n.translate('xpack.apm.views.serviceProfiling.title', { + defaultMessage: 'Profiling', + }), + element: , + }), + { + path: '/', + element: , + }, + ], +} as const; diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx new file mode 100644 index 0000000000000..37ec76f2b299e --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/service_detail/redirect_to_default_service_route_view.tsx @@ -0,0 +1,35 @@ +/* + * 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 React from 'react'; +import { Redirect } from 'react-router-dom'; +import qs from 'query-string'; +import { enableServiceOverview } from '../../../../common/ui_settings_keys'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmParams } from '../../../hooks/use_apm_params'; + +export function RedirectToDefaultServiceRouteView() { + const { + core: { uiSettings }, + } = useApmPluginContext(); + const { + path: { serviceName }, + query, + } = useApmParams('/services/:serviceName/*'); + + const search = qs.stringify(query); + + if (uiSettings.get(enableServiceOverview)) { + return ( + + ); + } + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/settings/create_agent_configuration_route_view.tsx b/x-pack/plugins/apm/public/components/routing/settings/create_agent_configuration_route_view.tsx new file mode 100644 index 0000000000000..b01882031bea5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/settings/create_agent_configuration_route_view.tsx @@ -0,0 +1,18 @@ +/* + * 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 React from 'react'; +import { AgentConfigurationPageStep } from '../../../../common/agent_configuration/constants'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { AgentConfigurationCreateEdit } from '../../app/Settings/agent_configurations/AgentConfigurationCreateEdit'; + +export function CreateAgentConfigurationRouteView() { + const { + query: { pageStep = AgentConfigurationPageStep.ChooseService }, + } = useApmParams('/settings/agent-configuration/create'); + + return ; +} diff --git a/x-pack/plugins/apm/public/components/routing/settings/edit_agent_configuration_route_view.tsx b/x-pack/plugins/apm/public/components/routing/settings/edit_agent_configuration_route_view.tsx new file mode 100644 index 0000000000000..70f1ce4d1d9db --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/settings/edit_agent_configuration_route_view.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { AgentConfigurationPageStep } from '../../../../common/agent_configuration/constants'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { AgentConfigurationCreateEdit } from '../../app/Settings/agent_configurations/AgentConfigurationCreateEdit'; + +export function EditAgentConfigurationRouteView() { + const { + query: { + name, + environment, + pageStep = AgentConfigurationPageStep.ChooseSettings, + }, + } = useApmParams('/settings/agent-configuration/edit'); + + const res = useFetcher( + (callApmApi) => { + return callApmApi({ + endpoint: 'GET /api/apm/settings/agent-configuration/view', + params: { query: { name, environment } }, + }); + }, + [name, environment] + ); + + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/settings/index.tsx b/x-pack/plugins/apm/public/components/routing/settings/index.tsx new file mode 100644 index 0000000000000..e844f05050d17 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/settings/index.tsx @@ -0,0 +1,140 @@ +/* + * 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 React from 'react'; +import * as t from 'io-ts'; +import { Outlet } from '@kbn/typed-react-router-config'; +import { i18n } from '@kbn/i18n'; +import { Redirect } from 'react-router-dom'; +import { agentConfigurationPageStepRt } from '../../../../common/agent_configuration/constants'; +import { Breadcrumb } from '../../app/breadcrumb'; +import { SettingsTemplate } from '../templates/settings_template'; +import { AgentConfigurations } from '../../app/Settings/agent_configurations'; +import { CreateAgentConfigurationRouteView } from './create_agent_configuration_route_view'; +import { EditAgentConfigurationRouteView } from './edit_agent_configuration_route_view'; +import { ApmIndices } from '../../app/Settings/ApmIndices'; +import { CustomizeUI } from '../../app/Settings/customize_ui'; +import { Schema } from '../../app/Settings/schema'; +import { AnomalyDetection } from '../../app/Settings/anomaly_detection'; + +function page({ + path, + title, + tab, + element, +}: { + path: TPath; + title: string; + tab: React.ComponentProps['selectedTab']; + element: React.ReactElement; +}): { + element: React.ReactElement; + path: TPath; +} { + return { + path, + element: ( + + {element} + + ), + } as any; +} + +export const settings = { + path: '/settings', + element: ( + + + + ), + children: [ + page({ + path: '/agent-configuration', + tab: 'agent-configurations', + title: i18n.translate( + 'xpack.apm.views.settings.agentConfiguration.title', + { defaultMessage: 'Agent Configuration' } + ), + element: , + }), + { + ...page({ + path: '/agent-configuration/create', + title: i18n.translate( + 'xpack.apm.views.settings.createAgentConfiguration.title', + { defaultMessage: 'Create Agent Configuration' } + ), + tab: 'agent-configurations', + element: , + }), + params: t.partial({ + query: t.partial({ + pageStep: agentConfigurationPageStepRt, + }), + }), + }, + { + ...page({ + path: '/agent-configuration/edit', + title: i18n.translate( + 'xpack.apm.views.settings.editAgentConfiguration.title', + { defaultMessage: 'Edit Agent Configuration' } + ), + tab: 'agent-configurations', + element: , + }), + params: t.partial({ + query: t.partial({ + name: t.string, + environment: t.string, + pageStep: agentConfigurationPageStepRt, + }), + }), + }, + page({ + path: '/apm-indices', + title: i18n.translate('xpack.apm.views.settings.indices.title', { + defaultMessage: 'Indices', + }), + tab: 'apm-indices', + element: , + }), + page({ + path: '/customize-ui', + title: i18n.translate('xpack.apm.views.settings.customizeUI.title', { + defaultMessage: 'Customize app', + }), + tab: 'customize-ui', + element: , + }), + page({ + path: '/schema', + title: i18n.translate('xpack.apm.views.settings.schema.title', { + defaultMessage: 'Schema', + }), + element: , + tab: 'schema', + }), + page({ + path: '/anomaly-detection', + title: i18n.translate('xpack.apm.views.settings.anomalyDetection.title', { + defaultMessage: 'Anomaly detection', + }), + element: , + tab: 'anomaly-detection', + }), + { + path: '/', + element: , + }, + ], +} as const; diff --git a/x-pack/plugins/apm/public/components/routing/telemetry_wrapper.tsx b/x-pack/plugins/apm/public/components/routing/telemetry_wrapper.tsx deleted file mode 100644 index fc3fc6a338d18..0000000000000 --- a/x-pack/plugins/apm/public/components/routing/telemetry_wrapper.tsx +++ /dev/null @@ -1,33 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { useTrackPageview } from '../../../../observability/public'; -import { APMRouteDefinition } from '../../application/routes'; -import { redirectTo } from './redirect_to'; - -export function TelemetryWrapper({ - route, - props, -}: { - route: APMRouteDefinition; - props: RouteComponentProps; -}) { - const { component, render, path } = route; - const pathAsString = path as string; - - useTrackPageview({ app: 'apm', path: pathAsString }); - useTrackPageview({ app: 'apm', path: pathAsString, delay: 15000 }); - - if (component) { - return React.createElement(component, props); - } - if (render) { - return <>{render(props)}; - } - return <>{redirectTo('/')}; -} diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx index b77b07a23455a..2e10c853f5429 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx @@ -16,6 +16,7 @@ import { EuiToolTip, EuiButtonEmpty, } from '@elastic/eui'; +import { omit } from 'lodash'; import { ApmMainTemplate } from './apm_main_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; @@ -28,13 +29,6 @@ import { import { ServiceIcons } from '../../shared/service_icons'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; -import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; -import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; -import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; -import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; import { @@ -47,22 +41,25 @@ import { createExploratoryViewUrl, SeriesUrl, } from '../../../../../observability/public'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; +import { useApmRouter } from '../../../hooks/use_apm_router'; type Tab = NonNullable[0] & { key: + | 'overview' + | 'transactions' | 'errors' | 'metrics' | 'nodes' - | 'overview' | 'service-map' - | 'profiling' - | 'transactions'; + | 'profiling'; hidden?: boolean; }; interface Props { + title: string; children: React.ReactNode; - serviceName: string; selectedTab: Tab['key']; searchBarOptions?: React.ComponentProps; } @@ -76,12 +73,27 @@ export function ApmServiceTemplate(props: Props) { } function TemplateWithContext({ + title, children, - serviceName, selectedTab, searchBarOptions, }: Props) { - const tabs = useTabs({ serviceName, selectedTab }); + const { + path: { serviceName }, + query, + } = useApmParams('/services/:serviceName/*'); + + const router = useApmRouter(); + + const tabs = useTabs({ selectedTab }); + + useBreadcrumb({ + title, + href: router.link(`/services/:serviceName/${selectedTab}` as const, { + path: { serviceName }, + query, + }), + }); return ( diff --git a/x-pack/plugins/apm/public/components/routing/track_pageview.tsx b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx new file mode 100644 index 0000000000000..20e02a505bc43 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx @@ -0,0 +1,18 @@ +/* + * 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 React from 'react'; +import { useRoutePath } from '@kbn/typed-react-router-config'; +import { useTrackPageview } from '../../../../observability/public'; + +export function TrackPageview({ children }: { children: React.ReactElement }) { + const routePath = useRoutePath(); + + useTrackPageview({ app: 'apm', path: routePath }); + useTrackPageview({ app: 'apm', path: routePath, delay: 15000 }); + + return children; +} diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index d5a685c4ea70b..28e674bc4150b 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -9,7 +9,7 @@ import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { History } from 'history'; import React from 'react'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, @@ -17,6 +17,7 @@ import { import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { fromQuery, toQuery } from '../Links/url_helpers'; +import { useApmParams } from '../../../hooks/use_apm_params'; function updateEnvironmentUrl( history: History, @@ -63,12 +64,12 @@ function getOptions(environments: string[]) { export function EnvironmentFilter() { const history = useHistory(); const location = useLocation(); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { path } = useApmParams('/*'); const { urlParams } = useUrlParams(); const { environment, start, end } = urlParams; const { environments, status = 'loading' } = useEnvironmentsFetcher({ - serviceName, + serviceName: 'serviceName' in path ? path.serviceName : undefined, start, end, }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx index d009f1f82b061..58c510eff13a4 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx @@ -26,14 +26,3 @@ export function editAgentConfigurationHref( }, }); } - -export function createAgentConfigurationHref( - search: string, - basePath: IBasePath -) { - return getAPMHref({ - basePath, - path: '/settings/agent-configuration/create', - search, - }); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts index a80c859459557..233d0821a5319 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts @@ -5,17 +5,15 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; export function useTransactionBreakdown() { - const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams: { environment, kuery, start, end, transactionName }, } = useUrlParams(); - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); const { data = { timeseries: undefined }, error, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx index a64355e47f757..e0f4ddb24c350 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/ml_header.tsx @@ -9,7 +9,6 @@ import { EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React from 'react'; -import { useParams } from 'react-router-dom'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; @@ -33,9 +32,8 @@ const ShiftedEuiText = euiStyled(EuiText)` `; export function MLHeader({ hasValidMlLicense, mlJobId }: Props) { - const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams } = useUrlParams(); - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); if (!hasValidMlLicense || !mlJobId) { return null; diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index 96cb7c49a6710..18c765c50fbf7 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -8,7 +8,6 @@ import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useParams } from 'react-router-dom'; import { RULE_ID } from '../../../../../../rule_registry/common/technical_rule_data_field_names'; import { AlertType } from '../../../../../common/alert_types'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; @@ -52,7 +51,6 @@ export function TransactionErrorRateChart({ showAnnotations = true, }: Props) { const theme = useTheme(); - const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams: { environment, @@ -64,7 +62,7 @@ export function TransactionErrorRateChart({ comparisonType, }, } = useUrlParams(); - const { transactionType, alerts } = useApmServiceContext(); + const { serviceName, transactionType, alerts } = useApmServiceContext(); const comparisonChartThem = getComparisonChartTheme(theme); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, diff --git a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx index f56a698885be4..72c5bac1f9f17 100644 --- a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { startsWith, uniqueId } from 'lodash'; import React, { useState } from 'react'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { esKuery, IIndexPattern, @@ -16,6 +16,7 @@ import { } from '../../../../../../../src/plugins/data/public'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmParams } from '../../../hooks/use_apm_params'; import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { getBoolFilter } from './get_bool_filter'; @@ -34,10 +35,11 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } export function KueryBar(props: { prepend?: React.ReactNode | string }) { - const { groupId, serviceName } = useParams<{ - groupId?: string; - serviceName?: string; - }>(); + const { path } = useApmParams('/*'); + + const serviceName = 'serviceName' in path ? path.serviceName : undefined; + const groupId = 'groupId' in path ? path.groupId : undefined; + const history = useHistory(); const [state, setState] = useState({ suggestions: [], diff --git a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx index ea2feb3d2a4ad..78ddb1e4560c5 100644 --- a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx +++ b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx @@ -6,8 +6,8 @@ */ import React, { createContext } from 'react'; -import { useParams } from 'react-router-dom'; import { Annotation } from '../../../common/annotations'; +import { useApmParams } from '../../hooks/use_apm_params'; import { useFetcher } from '../../hooks/use_fetcher'; import { useUrlParams } from '../url_params_context/use_url_params'; @@ -22,7 +22,10 @@ export function AnnotationsContextProvider({ }: { children: React.ReactNode; }) { - const { serviceName } = useParams<{ serviceName?: string }>(); + const { path } = useApmParams('/*'); + + const serviceName = 'serviceName' in path ? path.serviceName : undefined; + const { urlParams: { environment, start, end }, } = useUrlParams(); diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index a356f2962b3c6..5666c64376c20 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -5,14 +5,18 @@ * 2.0. */ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { Observable, of } from 'rxjs'; +import { RouterProvider } from '@kbn/typed-react-router-config'; +import { useHistory } from 'react-router-dom'; +import { createMemoryHistory, History } from 'history'; import { UrlService } from '../../../../../../src/plugins/share/common/url_service'; import { createObservabilityRuleTypeRegistryMock } from '../../../../observability/public'; import { ApmPluginContext, ApmPluginContextValue } from './apm_plugin_context'; import { ConfigSchema } from '../..'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { apmRouter } from '../../components/routing/apm_route_config'; import { MlLocatorDefinition } from '../../../../ml/public'; const uiSettings: Record = { @@ -124,21 +128,32 @@ export const mockApmPluginContextValue = { export function MockApmPluginContextWrapper({ children, value = {} as ApmPluginContextValue, + history, }: { children?: React.ReactNode; value?: ApmPluginContextValue; + history?: History; }) { if (value.core) { createCallApmApi(value.core); } + + const contextHistory = useHistory(); + + const usedHistory = useMemo(() => { + return history || contextHistory || createMemoryHistory(); + }, [history, contextHistory]); + return ( - - {children} - + + + {children} + + ); } diff --git a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.test.tsx b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.test.tsx index 1379bd8603999..8751145081f4a 100644 --- a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.test.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.test.tsx @@ -13,7 +13,7 @@ describe('getTransactionType', () => { expect( getTransactionType({ transactionTypes: ['worker', 'request'], - urlParams: { transactionType: 'custom' }, + transactionType: 'custom', agentName: 'nodejs', }) ).toBe('custom'); @@ -25,7 +25,6 @@ describe('getTransactionType', () => { expect( getTransactionType({ transactionTypes: [], - urlParams: {}, }) ).toBeUndefined(); }); @@ -37,7 +36,6 @@ describe('getTransactionType', () => { expect( getTransactionType({ transactionTypes: ['worker', 'request'], - urlParams: {}, agentName: 'nodejs', }) ).toEqual('request'); @@ -49,7 +47,6 @@ describe('getTransactionType', () => { expect( getTransactionType({ transactionTypes: ['worker', 'custom'], - urlParams: {}, agentName: 'nodejs', }) ).toEqual('worker'); @@ -62,7 +59,6 @@ describe('getTransactionType', () => { expect( getTransactionType({ transactionTypes: ['http-request', 'page-load'], - urlParams: {}, agentName: 'js-base', }) ).toEqual('page-load'); diff --git a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx index cb826763425c2..1f454855b0f25 100644 --- a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx @@ -13,40 +13,39 @@ import { TRANSACTION_REQUEST, } from '../../../common/transaction_types'; import { useServiceTransactionTypesFetcher } from './use_service_transaction_types_fetcher'; -import { useUrlParams } from '../url_params_context/use_url_params'; import { useServiceAgentNameFetcher } from './use_service_agent_name_fetcher'; -import { IUrlParams } from '../url_params_context/types'; import { APIReturnType } from '../../services/rest/createCallApmApi'; import { useServiceAlertsFetcher } from './use_service_alerts_fetcher'; -import { useServiceName } from '../../hooks/use_service_name'; +import { useApmParams } from '../../hooks/use_apm_params'; export type APMServiceAlert = ValuesType< APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts'] >; export const APMServiceContext = createContext<{ + serviceName: string; agentName?: string; transactionType?: string; transactionTypes: string[]; alerts: APMServiceAlert[]; - serviceName?: string; -}>({ transactionTypes: [], alerts: [] }); +}>({ serviceName: '', transactionTypes: [], alerts: [] }); export function ApmServiceContextProvider({ children, }: { children: ReactNode; }) { - const { urlParams } = useUrlParams(); - - const serviceName = useServiceName(); + const { + path: { serviceName }, + query, + } = useApmParams('/services/:serviceName'); const { agentName } = useServiceAgentNameFetcher(serviceName); const transactionTypes = useServiceTransactionTypesFetcher(serviceName); const transactionType = getTransactionType({ - urlParams, + transactionType: query.transactionType, transactionTypes, agentName, }); @@ -56,11 +55,11 @@ export function ApmServiceContextProvider({ return ( @@ -68,16 +67,16 @@ export function ApmServiceContextProvider({ } export function getTransactionType({ - urlParams, + transactionType, transactionTypes, agentName, }: { - urlParams: IUrlParams; + transactionType?: string; transactionTypes: string[]; agentName?: string; }) { - if (urlParams.transactionType) { - return urlParams.transactionType; + if (transactionType) { + return transactionType; } if (!agentName || transactionTypes.length === 0) { diff --git a/x-pack/plugins/apm/public/context/breadcrumbs/context.tsx b/x-pack/plugins/apm/public/context/breadcrumbs/context.tsx new file mode 100644 index 0000000000000..906d2b19abf9f --- /dev/null +++ b/x-pack/plugins/apm/public/context/breadcrumbs/context.tsx @@ -0,0 +1,92 @@ +/* + * 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 { + Route, + RouteMatch, + useMatchRoutes, +} from '@kbn/typed-react-router-config'; +import { ChromeBreadcrumb } from 'kibana/public'; +import { compact, isEqual } from 'lodash'; +import React, { createContext, useMemo, useState } from 'react'; +import { useBreadcrumbs } from '../../../../observability/public'; + +export interface Breadcrumb { + title: string; + href: string; +} + +interface BreadcrumbApi { + set(route: Route, breadcrumb: Breadcrumb[]): void; + unset(route: Route): void; + getBreadcrumbs(matches: RouteMatch[]): Breadcrumb[]; +} + +export const BreadcrumbsContext = createContext( + undefined +); + +export function BreadcrumbsContextProvider({ + children, +}: { + children: React.ReactElement; +}) { + const [, forceUpdate] = useState({}); + + const breadcrumbs = useMemo(() => { + return new Map(); + }, []); + + const matches: RouteMatch[] = useMatchRoutes(); + + const api = useMemo( + () => ({ + set(route, breadcrumb) { + if (!isEqual(breadcrumbs.get(route), breadcrumb)) { + breadcrumbs.set(route, breadcrumb); + forceUpdate({}); + } + }, + unset(route) { + if (breadcrumbs.has(route)) { + breadcrumbs.delete(route); + forceUpdate({}); + } + }, + getBreadcrumbs(currentMatches: RouteMatch[]) { + return compact( + currentMatches.flatMap((match) => { + const breadcrumb = breadcrumbs.get(match.route); + + return breadcrumb; + }) + ); + }, + }), + [breadcrumbs] + ); + + const formattedBreadcrumbs: ChromeBreadcrumb[] = api + .getBreadcrumbs(matches) + .map((breadcrumb, index, array) => { + return { + text: breadcrumb.title, + ...(index === array.length - 1 + ? {} + : { + href: breadcrumb.href, + }), + }; + }); + + useBreadcrumbs(formattedBreadcrumbs); + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts new file mode 100644 index 0000000000000..dfc33c0f10ffc --- /dev/null +++ b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts @@ -0,0 +1,42 @@ +/* + * 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 { useCurrentRoute } from '@kbn/typed-react-router-config'; +import { useContext, useEffect, useRef } from 'react'; +import { castArray } from 'lodash'; +import { Breadcrumb, BreadcrumbsContext } from './context'; + +export function useBreadcrumb(breadcrumb: Breadcrumb | Breadcrumb[]) { + const api = useContext(BreadcrumbsContext); + + if (!api) { + throw new Error('Missing Breadcrumb API in context'); + } + + const { match } = useCurrentRoute(); + + const matchedRoute = useRef(match?.route); + + if (matchedRoute.current && matchedRoute.current !== match?.route) { + api.unset(matchedRoute.current); + } + + matchedRoute.current = match?.route; + + if (matchedRoute.current) { + api.set(matchedRoute.current, castArray(breadcrumb)); + } + + useEffect(() => { + return () => { + if (matchedRoute.current) { + api.unset(matchedRoute.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx deleted file mode 100644 index 1cdb84c324750..0000000000000 --- a/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.test.tsx +++ /dev/null @@ -1,153 +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 - * 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 produce from 'immer'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { apmRouteConfig } from '../components/routing/apm_route_config'; -import { ApmPluginContextValue } from '../context/apm_plugin/apm_plugin_context'; -import { - mockApmPluginContextValue, - MockApmPluginContextWrapper, -} from '../context/apm_plugin/mock_apm_plugin_context'; -import { useApmBreadcrumbs } from './use_apm_breadcrumbs'; -import { useBreadcrumbs } from '../../../observability/public'; - -jest.mock('../../../observability/public'); - -function createWrapper(path: string) { - return ({ children }: { children?: ReactNode }) => { - const value = (produce(mockApmPluginContextValue, (draft) => { - draft.core.application.navigateToUrl = (url: string) => Promise.resolve(); - }) as unknown) as ApmPluginContextValue; - - return ( - - - {children} - - - ); - }; -} - -function mountBreadcrumb(path: string) { - renderHook(() => useApmBreadcrumbs(apmRouteConfig), { - wrapper: createWrapper(path), - }); -} - -describe('useApmBreadcrumbs', () => { - test('/services/:serviceName/errors/:groupId', () => { - mountBreadcrumb( - '/services/opbeans-node/errors/myGroupId?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0' - ); - - expect(useBreadcrumbs).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - text: 'APM', - href: - '/basepath/app/apm/?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', - }), - expect.objectContaining({ - text: 'Services', - href: - '/basepath/app/apm/services?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', - }), - expect.objectContaining({ - text: 'opbeans-node', - href: - '/basepath/app/apm/services/opbeans-node?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', - }), - expect.objectContaining({ - text: 'Errors', - href: - '/basepath/app/apm/services/opbeans-node/errors?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', - }), - expect.objectContaining({ text: 'myGroupId', href: undefined }), - ]) - ); - }); - - test('/services/:serviceName/errors', () => { - mountBreadcrumb('/services/opbeans-node/errors?kuery=myKuery'); - - expect(useBreadcrumbs).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - text: 'APM', - href: '/basepath/app/apm/?kuery=myKuery', - }), - expect.objectContaining({ - text: 'Services', - href: '/basepath/app/apm/services?kuery=myKuery', - }), - expect.objectContaining({ - text: 'opbeans-node', - href: '/basepath/app/apm/services/opbeans-node?kuery=myKuery', - }), - expect.objectContaining({ text: 'Errors', href: undefined }), - ]) - ); - }); - - test('/services/:serviceName/transactions', () => { - mountBreadcrumb('/services/opbeans-node/transactions?kuery=myKuery'); - - expect(useBreadcrumbs).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - text: 'APM', - href: '/basepath/app/apm/?kuery=myKuery', - }), - expect.objectContaining({ - text: 'Services', - href: '/basepath/app/apm/services?kuery=myKuery', - }), - expect.objectContaining({ - text: 'opbeans-node', - href: '/basepath/app/apm/services/opbeans-node?kuery=myKuery', - }), - expect.objectContaining({ text: 'Transactions', href: undefined }), - ]) - ); - }); - - test('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { - mountBreadcrumb( - '/services/opbeans-node/transactions/view?kuery=myKuery&transactionName=my-transaction-name' - ); - - expect(useBreadcrumbs).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - text: 'APM', - href: '/basepath/app/apm/?kuery=myKuery', - }), - expect.objectContaining({ - text: 'Services', - href: '/basepath/app/apm/services?kuery=myKuery', - }), - expect.objectContaining({ - text: 'opbeans-node', - href: '/basepath/app/apm/services/opbeans-node?kuery=myKuery', - }), - expect.objectContaining({ - text: 'Transactions', - href: - '/basepath/app/apm/services/opbeans-node/transactions?kuery=myKuery', - }), - expect.objectContaining({ - text: 'my-transaction-name', - href: undefined, - }), - ]) - ); - }); -}); diff --git a/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts b/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts deleted file mode 100644 index d64bcadf79577..0000000000000 --- a/x-pack/plugins/apm/public/hooks/use_apm_breadcrumbs.ts +++ /dev/null @@ -1,196 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { History, Location } from 'history'; -import { ChromeBreadcrumb } from 'kibana/public'; -import { MouseEvent } from 'react'; -import { - match as Match, - matchPath, - RouteComponentProps, - useHistory, - useLocation, -} from 'react-router-dom'; -import { useBreadcrumbs } from '../../../observability/public'; -import { APMRouteDefinition, BreadcrumbTitle } from '../application/routes'; -import { getAPMHref } from '../components/shared/Links/apm/APMLink'; -import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; - -interface BreadcrumbWithoutLink extends ChromeBreadcrumb { - match: Match>; -} - -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( - (found, { breadcrumb, ...routeDefinition }) => { - if (found) { - return found; - } - - if (!breadcrumb) { - return null; - } - - const match = matchPath>( - 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; - 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) => { - 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; -} - -/** - * Determine the breadcrumbs from the routes, set them, and update the page - * title when the route changes. - */ -export function useApmBreadcrumbs(routes: APMRouteDefinition[]) { - const history = useHistory(); - const location = useLocation(); - const { search } = location; - const { core } = useApmPluginContext(); - const { basePath } = core.http; - const { navigateToUrl } = core.application; - - function wrappedGetAPMHref(path: string) { - return getAPMHref({ basePath, path, search }); - } - - const breadcrumbsWithoutLinks = routeDefinitionsToBreadcrumbs({ - history, - location, - routes, - }); - const breadcrumbs = addLinksToBreadcrumbs({ - breadcrumbs: breadcrumbsWithoutLinks, - wrappedGetAPMHref, - navigateToUrl, - }); - - useBreadcrumbs(breadcrumbs); -} diff --git a/x-pack/plugins/apm/public/hooks/use_apm_params.ts b/x-pack/plugins/apm/public/hooks/use_apm_params.ts new file mode 100644 index 0000000000000..d7661dbcf4d21 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_apm_params.ts @@ -0,0 +1,15 @@ +/* + * 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 { OutputOf, PathsOf, useParams } from '@kbn/typed-react-router-config'; +import { ApmRoutes } from '../components/routing/apm_route_config'; + +export function useApmParams>( + path: TPath +): OutputOf { + return useParams(path as never); +} diff --git a/x-pack/plugins/apm/public/hooks/use_apm_router.ts b/x-pack/plugins/apm/public/hooks/use_apm_router.ts new file mode 100644 index 0000000000000..c0ccc37cc897d --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_apm_router.ts @@ -0,0 +1,26 @@ +/* + * 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 { useRouter } from '@kbn/typed-react-router-config'; +import type { ApmRouter } from '../components/routing/apm_route_config'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; + +export function useApmRouter() { + const router = useRouter(); + const { core } = useApmPluginContext(); + + const link = (...args: any[]) => { + // a little too much effort needed to satisfy TS here + // @ts-ignore + return core.http.basePath.prepend('/app/apm' + router.link(...args)); + }; + + return ({ + ...router, + link, + } as unknown) as ApmRouter; +} diff --git a/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts index baf3eb51ae033..37eca08225e8f 100644 --- a/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { MetricsChartsByAgentAPIResponse } from '../../server/lib/metrics/get_metrics_chart_data_by_agent'; import { useUrlParams } from '../context/url_params_context/use_url_params'; @@ -24,8 +23,7 @@ export function useServiceMetricChartsFetcher({ const { urlParams: { environment, kuery, start, end }, } = useUrlParams(); - const { agentName } = useApmServiceContext(); - const { serviceName } = useParams<{ serviceName?: string }>(); + const { agentName, serviceName } = useApmServiceContext(); const { data = INITIAL_DATA, error, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/hooks/use_service_name.tsx b/x-pack/plugins/apm/public/hooks/use_service_name.tsx index c003bf5223a32..5e2678374c68e 100644 --- a/x-pack/plugins/apm/public/hooks/use_service_name.tsx +++ b/x-pack/plugins/apm/public/hooks/use_service_name.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import { useRouteMatch } from 'react-router-dom'; +import { useApmParams } from './use_apm_params'; export function useServiceName(): string | undefined { - const match = useRouteMatch<{ serviceName?: string }>( - '/services/:serviceName' - ); + const { path } = useApmParams('/*'); - return match ? match.params.serviceName : undefined; + return 'serviceName' in path ? path.serviceName : undefined; } diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts index 25632d4b19cf4..8e48f386772b3 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts @@ -6,12 +6,13 @@ */ import { flatten, omit, isEmpty } from 'lodash'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; import { maybe } from '../../common/utils/maybe'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { useUrlParams } from '../context/url_params_context/use_url_params'; +import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; type APIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/distribution'>; @@ -21,19 +22,15 @@ const INITIAL_DATA = { bucketSize: 0, }; -export function useTransactionDistributionFetcher() { - const { serviceName } = useParams<{ serviceName?: string }>(); +export function useTransactionDistributionFetcher({ + transactionName, +}: { + transactionName: string; +}) { + const { serviceName, transactionType } = useApmServiceContext(); + const { - urlParams: { - environment, - kuery, - start, - end, - transactionType, - transactionId, - traceId, - transactionName, - }, + urlParams: { environment, kuery, start, end, transactionId, traceId }, } = useUrlParams(); const history = useHistory(); diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 0f1592ca2679f..5ae4a138608ec 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -6,7 +6,6 @@ */ import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; @@ -15,8 +14,7 @@ import { useTheme } from './use_theme'; import { getTimeRangeComparison } from '../components/shared/time_comparison/get_time_range_comparison'; export function useTransactionLatencyChartsFetcher() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); const theme = useTheme(); const { urlParams: { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts index c8ae4fa5823a4..72e469178a100 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts @@ -6,7 +6,6 @@ */ import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; import { getThroughputChartSelector } from '../selectors/throughput_chart_selectors'; @@ -14,8 +13,7 @@ import { useTheme } from './use_theme'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; export function useTransactionThroughputChartsFetcher() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { transactionType } = useApmServiceContext(); + const { transactionType, serviceName } = useApmServiceContext(); const theme = useTheme(); const { urlParams: { environment, kuery, start, end, transactionName }, diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 9a1d4da8ece7c..465155dbf166b 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -67,13 +67,13 @@ export function mockMoment() { // Useful for getting the rendered href from any kind of link component export async function getRenderedHref(Component: React.FC, location: Location) { const el = render( - - + + - - + + ); const a = el.container.querySelector('a'); diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js index 88d2e169dd542..89c5055c6a7f7 100644 --- a/x-pack/plugins/apm/scripts/precommit.js +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -71,6 +71,8 @@ const tasks = new Listr( resolve(__dirname, '../../../../node_modules/jest-silent-reporter'), '--collect-coverage', 'false', + '--maxWorkers', + 4, ], execaOpts ), diff --git a/yarn.lock b/yarn.lock index dbb034fa3e790..0199fabf82043 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2931,6 +2931,10 @@ version "0.0.0" uid "" +"@kbn/typed-react-router-config@link:bazel-bin/packages/kbn-typed-react-router-config": + version "0.0.0" + uid "" + "@kbn/ui-framework@link:bazel-bin/packages/kbn-ui-framework": version "0.0.0" uid "" @@ -5879,6 +5883,15 @@ dependencies: "@types/react" "*" +"@types/react-router-config@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.2.tgz#4d3b52e71ed363a1976a12321e67b09a99ad6d10" + integrity sha512-WOSetDV3YPxbkVJAdv/bqExJjmcdCi/vpCJh3NfQOy1X15vHMSiMioXIcGekXDJJYhqGUMDo9e337mh508foAA== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-router" "*" + "@types/react-router-dom@^5.1.5": version "5.1.5" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" @@ -23280,6 +23293,13 @@ react-reverse-portal@^1.0.4: resolved "https://registry.yarnpkg.com/react-reverse-portal/-/react-reverse-portal-1.0.4.tgz#d127d2c9147549b25c4959aba1802eca4b144cd4" integrity sha512-WESex/wSjxHwdG7M0uwPNkdQXaLauXNHi4INQiRybmFIXVzAqgf/Ak2OzJ4MLf4UuCD/IzEwJOkML2SxnnontA== +react-router-config@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" + integrity sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg== + dependencies: + "@babel/runtime" "^7.1.2" + react-router-dom@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662"