diff --git a/.changeset/red-worms-fold.md b/.changeset/red-worms-fold.md new file mode 100644 index 0000000000..41dcc75c4c --- /dev/null +++ b/.changeset/red-worms-fold.md @@ -0,0 +1,9 @@ +--- +'@backstage/core-api': patch +--- + +Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. + +Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. + +Add an as of yet unused `params` option to `createRouteRef`. diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx index a69ee9de77..38571fdcc7 100644 --- a/packages/core-api/src/extensions/traversal.test.tsx +++ b/packages/core-api/src/extensions/traversal.test.tsx @@ -41,11 +41,14 @@ describe('discovery', () => { root, discoverers: [childDiscoverer], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); @@ -85,11 +88,14 @@ describe('discovery', () => { ), ], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts index a1fbdab25a..bdf02d16c8 100644 --- a/packages/core-api/src/extensions/traversal.ts +++ b/packages/core-api/src/extensions/traversal.ts @@ -23,7 +23,7 @@ export type Collector = () => { visit( accumulator: Result, element: ReactElement, - parent: ReactElement, + parent: ReactElement | undefined, context: Context, ): Context; }; @@ -33,7 +33,7 @@ export type Collector = () => { * varying methods to discover child nodes and collect data along the way. */ export function traverseElementTree(options: { - root: ReactElement; + root: ReactNode; discoverers: Discoverer[]; collectors: { [name in keyof Results]: Collector }; }): Results { @@ -52,14 +52,14 @@ export function traverseElementTree(options: { // Internal representation of an element in the tree that we're iterating over type QueueItem = { node: ReactNode; - parent: ReactElement; + parent: ReactElement | undefined; contexts: { [name in string]: unknown }; }; const queue = [ { node: Children.toArray(options.root), - parent: options.root, + parent: undefined, contexts: {}, } as QueueItem, ]; @@ -120,10 +120,10 @@ export function traverseElementTree(options: { } export function createCollector( - initialResult: Result, + accumulatorFactory: () => Result, visit: ReturnType>['visit'], ): Collector { - return () => ({ accumulator: initialResult, visit }); + return () => ({ accumulator: accumulatorFactory(), visit }); } export function childDiscoverer(element: ReactElement): ReactNode { diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts index a222746f9e..3eadfc0185 100644 --- a/packages/core-api/src/plugin/collectors.ts +++ b/packages/core-api/src/plugin/collectors.ts @@ -34,7 +34,7 @@ import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; export const pluginCollector = createCollector( - new Set(), + () => new Set(), (acc, node) => { const plugin = getComponentData(node, 'core.plugin'); if (plugin) { diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index c33335cb38..0fbc57c6f1 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,44 +14,10 @@ * limitations under the License. */ -import { - ConcreteRoute, - routeReference, - ReferencedRoute, - resolveRoute, - RouteRefConfig, -} from './types'; -import { generatePath } from 'react-router-dom'; +import { RouteRefConfig, RouteRef } from './types'; -type SubRouteConfig = { - path: string; -}; - -export class SubRouteRef - implements ReferencedRoute { - constructor( - private readonly parent: ConcreteRoute, - private readonly config: SubRouteConfig, - ) {} - - get [routeReference]() { - return this; - } - - link(...args: Args): ConcreteRoute { - return { - [routeReference]: this, - [resolveRoute]: (path: string) => { - const ownPart = generatePath(this.config.path, args[0] ?? {}); - const parentPart = this.parent[resolveRoute](path); - return parentPart + ownPart; - }, - }; - } -} - -export class AbsoluteRouteRef implements ConcreteRoute { - constructor(private readonly config: RouteRefConfig) {} +export class AbsoluteRouteRef { + constructor(private readonly config: RouteRefConfig) {} get icon() { return this.config.icon; @@ -66,26 +32,24 @@ export class AbsoluteRouteRef implements ConcreteRoute { return this.config.title; } - createSubRoute( - config: SubRouteConfig, - ) { - return new SubRouteRef(this, config); + /** + * This function should not be used, create a separate RouteRef instead + * @deprecated + */ + createSubRoute(): any { + throw new Error( + 'This method should not be called, create a separate RouteRef instead', + ); } - get [routeReference]() { - return this; - } - - [resolveRoute](path: string) { - return path; + toString() { + return `routeRef{path=${this.path}}`; } } -export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { - return new AbsoluteRouteRef(config); +export function createRouteRef< + ParamKeys extends string, + Params extends { [param in string]: string } = { [name in ParamKeys]: string } +>(config: RouteRefConfig): RouteRef { + return new AbsoluteRouteRef(config); } - -// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone -// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider -// a different model for how to create sub routes, just avoid this -export type MutableRouteRef = AbsoluteRouteRef; diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts deleted file mode 100644 index fa1ef584f1..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { RouteRefRegistry } from './RouteRefRegistry'; -import { createRouteRef } from './RouteRef'; - -const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; -const ref1 = createRouteRef(dummyConfig); -const ref11 = createRouteRef(dummyConfig); -const ref12 = createRouteRef(dummyConfig); -const ref121 = createRouteRef(dummyConfig); -const ref2 = createRouteRef(dummyConfig); -const ref2a = ref2.createSubRoute({ path: '/a' }); -const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); - -describe('RouteRefRegistry', () => { - it('should be constructed with a root route', () => { - const registry = new RouteRefRegistry(); - expect(registry.resolveRoute([], [])).toBe(''); - }); - - it('should register and resolve some absolute routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); - expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( - false, - ); - expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref11])).toBe(undefined); - expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); - expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); - }); - - it('should register and resolve with sub routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2a], '2')).toBe(true); - expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2b], '2')).toBe(true); - expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref2])).toBe('/2'); - expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); - expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( - '/2/b/abc/1', - ); - expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( - '/2/b/xyz/2', - ); - expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( - '/2/b/abc/2', - ); - expect( - registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), - ).toBe('/2/b/abc/2'); - }); - - it('should throw when registering routes incorrectly', () => { - const registry = new RouteRefRegistry(); - expect(() => { - registry.registerRoute([ref1, ref11], '11'); - }).toThrow('Could not find parent for new routing node'); - expect(() => { - registry.registerRoute([], '11'); - }).toThrow('Must provide at least 1 route to add routing node'); - }); -}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts deleted file mode 100644 index 7e55cbe8f7..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ConcreteRoute, - routeReference, - resolveRoute, - ReferencedRoute, -} from './types'; - -const rootRoute: ConcreteRoute = { - get [routeReference]() { - return this; - }, - [resolveRoute]: () => '', -}; - -export type RouteRefResolver = { - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; -}; - -class Node { - readonly children = new Map(); - - constructor(readonly path: string, readonly parent: Node | undefined) {} - - /** - * Look up a node in the tree given a path. - */ - findNode(routes: ReferencedRoute[]): Node | undefined { - let node = this as Node | undefined; - - for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i][routeReference]); - } - - return node; - } - - /** - * Assigns a path to a leaf node in the routing tree. All ancestor - * nodes of the new leaf node must already exist, or an error will be thrown. - * - * Returns true if the node was added, or false if the node already existed. - */ - addNode(routes: ReferencedRoute[], path: string): boolean { - if (routes.length === 0) { - throw new Error('Must provide at least 1 route to add routing node'); - } - - const parentNode = this.findNode(routes.slice(0, -1)); - if (!parentNode) { - throw new Error('Could not find parent for new routing node'); - } - - const lastRoute = routes[routes.length - 1]; - const lastRouteRef = lastRoute[routeReference]; - - const existingNode = parentNode.children.get(lastRouteRef); - if (existingNode) { - return existingNode.path === path; - } - - parentNode.children.set(lastRouteRef, new Node(path, parentNode)); - return true; - } - - /** - * Resolve an absolute URL that represents this node in the routing tree, using - * using the supplied concrete routes and ancestors of this node. - * - * The length of the provided routes array must match the depth of - * the routing tree that this node is at, or an error will be thrown. - */ - resolve(routes: ConcreteRoute[]) { - const parts = Array(routes.length); - - let node = this as Node | undefined; - for (let i = routes.length - 1; i >= 0; i--) { - if (!node) { - throw new Error('Route resolve missing required parent'); - } - - const route = routes[i]; - parts[i] = route[resolveRoute](node.path); - - node = node.parent; - } - - if (node) { - throw new Error('Route resolve did not reach root'); - } - - return parts.join('/'); - } -} - -/** - * A registry for resolving route refs into concrete string routes. - */ -export class RouteRefRegistry { - private readonly root = new Node('', undefined); - - /** - * Register a new leaf path for a sequence of routes. All ancestor - * routes must already exist. - */ - registerRoute(routes: ReferencedRoute[], path: string): boolean { - return this.root.addNode(routes, path); - } - - /** - * Resolve an absolute path from a point in the routing tree. - * - * The route referenced by `from` must exist, and is the starting - * point for the search, walking up the tree until a subtree that - * matches the routes reference in `to` are found. - * - * If `from` is empty, the search starts and ends at the root node. - * If `to` is empty, the route referenced by `from` will always be returned. - */ - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { - // Keep track of the `from` routes and pop the last ones as we traverse up - // the routing tree. The list of concrete routes that we're passing to - // `node.resolve()` should only include the ones in the resolve path. - const concreteStack = from.slice(); - - let fromNode = this.root.findNode(from); - while (fromNode) { - const resolvedNode = fromNode.findNode(to); - if (resolvedNode) { - return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); - } - - // Search at this level of the tree failed, move up to parent - concreteStack.pop(); - fromNode = fromNode.parent; - } - - return undefined; - } -} diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index c2eefa7e82..2595b8c0a0 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -15,7 +15,7 @@ */ import React, { PropsWithChildren } from 'react'; -import { routeCollector, routeParentCollector } from './collectors'; +import { routePathCollector, routeParentCollector } from './collectors'; import { traverseElementTree, @@ -96,7 +96,7 @@ describe('discovery', () => { root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }); @@ -147,7 +147,7 @@ describe('discovery', () => { root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }); @@ -184,7 +184,7 @@ describe('discovery', () => { ), discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }), diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index fab0cbe112..e47a59aa85 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -14,69 +14,85 @@ * limitations under the License. */ -import { isValidElement, ReactNode } from 'react'; -import { RouteRef } from '../routing/types'; +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { BackstageRouteObject, RouteRef } from '../routing/types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; -export const routeCollector = createCollector( - new Map(), +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + +export const routePathCollector = createCollector( + () => new Map(), (acc, node, parent) => { - if (parent.props.element === node) { + if (parent?.props.element === node) { return; } - const path: string | undefined = node.props?.path; - const element: ReactNode = node.props?.element; - - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { + const path: string | undefined = node.props?.path; if (!path) { throw new Error('Mounted routable extension must have a path'); } acc.set(routeRef, path); - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - if (elementRouteRef) { - if (!path) { - throw new Error('Route element must have a path'); - } - acc.set(elementRouteRef, path); - } } }, ); export const routeParentCollector = createCollector( - new Map(), + () => new Map(), (acc, node, parent, parentRouteRef?: RouteRef) => { - if (parent.props.element === node) { + if (parent?.props.element === node) { return parentRouteRef; } - const element: ReactNode = node.props?.element; - let nextParent = parentRouteRef; - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { acc.set(routeRef, parentRouteRef); nextParent = routeRef; - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - - if (elementRouteRef) { - acc.set(elementRouteRef, parentRouteRef); - nextParent = elementRouteRef; - } } return nextParent; }, ); + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentChildArr: BackstageRouteObject[] = acc) => { + if (parent?.props.element === node) { + return parentChildArr; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + + const routeRef = getMountPoint(node); + if (routeRef) { + const children: BackstageRouteObject[] = []; + if (!path) { + throw new Error(`No path found for mount point ${routeRef}`); + } + parentChildArr.push({ + caseSensitive, + path, + element: null, + routeRef, + children, + }); + return children; + } + + return parentChildArr; + }, +); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx new file mode 100644 index 0000000000..ae1053b58d --- /dev/null +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -0,0 +1,247 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React, { PropsWithChildren, ReactElement } from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { createPlugin } from '../plugin'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; +import { + useRouteRef, + RoutingProvider, + validateRoutes, + RouteFunc, +} from './hooks'; +import { createRouteRef } from './RouteRef'; +import { RouteRef, RouteRefConfig } from './types'; + +const mockConfig = (extra?: Partial>) => ({ + path: '/unused', + title: 'Unused', + ...extra, +}); +const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); +const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); +const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); +const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); +const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); + +const MockRouteSource = (props: { + name: string; + routeRef: RouteRef; + params?: T; +}) => { + try { + const routeFunc = useRouteRef(props.routeRef) as RouteFunc; + return ( +
+ Path at {props.name}: {routeFunc(props.params)} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref1 }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ component: MockRouteSource, mountPoint: ref2 }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref3 }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ component: MockRouteSource, mountPoint: ref4 }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref5 }), +); + +function withRoutingProvider(root: ReactElement) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + +describe('discovery', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => { + const root = ( + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect(rendered.getByText('Path at inside: /foo/bar')).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', () => { + const root = ( + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText('Path at inside: /foo/bar/bleb'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); + }); + + it('should handle relative routing within parameterized routePaths', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText('Path at inside: /foo/blob/baz'), + ).toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routePaths, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routePaths, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); +}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx new file mode 100644 index 0000000000..563bc84a76 --- /dev/null +++ b/packages/core-api/src/routing/hooks.tsx @@ -0,0 +1,183 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { createContext, ReactNode, useContext, useMemo } from 'react'; +import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types'; +import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params[keyof Params] extends never + ? readonly [] + : readonly [Params] +) => string; + +class RouteResolver { + constructor( + private readonly routePaths: Map, + private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], + ) {} + + resolve( + routeRef: RouteRef, + sourceLocation: ReturnType, + ): RouteFunc { + const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; + + const lastPath = this.routePaths.get(routeRef); + if (!lastPath) { + throw new Error(`No path for ${routeRef}`); + } + const targetRefStack = Array(); + let matchIndex = -1; + + for ( + let currentRouteRef: AnyRouteRef | undefined = routeRef; + currentRouteRef; + currentRouteRef = this.routeParents.get(currentRouteRef) + ) { + matchIndex = match.findIndex( + m => (m.route as BackstageRouteObject).routeRef === currentRouteRef, + ); + if (matchIndex !== -1) { + break; + } + + targetRefStack.unshift(currentRouteRef); + } + + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (targetRefStack.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the called would have no way of knowing + // what parameters those are. + const prefixPath = targetRefStack + .slice(0, -1) + .map(ref => { + const path = this.routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${routeRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }) + .join('/') + .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s + + const routeFunc: RouteFunc = (...[params]) => { + return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; + }; + return routeFunc; + } +} + +const RoutingContext = createContext(undefined); + +export function useRouteRef( + routeRef: RouteRef, +): RouteFunc { + const sourceLocation = useLocation(); + const resolver = useContext(RoutingContext); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!routeFunc) { + throw new Error('No route resolver found in context'); + } + + return routeFunc; +} + +type ProviderProps = { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routePaths, + routeParents, + routeObjects, + children, +}: ProviderProps) => { + const resolver = new RouteResolver(routePaths, routeParents, routeObjects); + return ( + + {children} + + ); +}; + +export function validateRoutes( + routePaths: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: AnyRouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routePaths.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 29de34ec42..d682aa9348 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ -export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; -export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; +export type { + RouteRef, + RouteRefConfig, + AbsoluteRouteRef, + ConcreteRoute, + MutableRouteRef, +} from './types'; export { createRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 162ac74bde..e91ca7ea53 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,26 +16,51 @@ import { IconComponent } from '../icons'; -export const resolveRoute = Symbol('resolve-route'); -export const routeReference = Symbol('route-ref'); - -export type ReferencedRoute = { - [routeReference]: unknown; -}; - -export type ConcreteRoute = ReferencedRoute & { - [resolveRoute](path: string): string; -}; - -export type RouteRef = { +// @ts-ignore, we're just embedding the Params type for usage in other places +export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead path: string; icon?: IconComponent; title: string; + /** + * This function should not be used, create a separate RouteRef instead + * @deprecated + */ + createSubRoute(): any; }; -export type RouteRefConfig = { +export type AnyRouteRef = RouteRef; + +/** + * This type should not be used + * @deprecated + */ +export type ConcreteRoute = {}; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type AbsoluteRouteRef = RouteRef<{}>; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type MutableRouteRef = RouteRef<{}>; + +export type RouteRefConfig = { + params?: Array; path: string; icon?: IconComponent; title: string; }; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRef: AnyRouteRef; +}