Merge pull request #3605 from backstage/mob/routin
core-api: add another set of composability plumbing
This commit is contained in:
@@ -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`.
|
||||
@@ -41,11 +41,14 @@ describe('discovery', () => {
|
||||
root,
|
||||
discoverers: [childDiscoverer],
|
||||
collectors: {
|
||||
names: createCollector(Array<string>(), (acc, el) => {
|
||||
if (typeof el.type === 'string') {
|
||||
acc.push(el.type);
|
||||
}
|
||||
}),
|
||||
names: createCollector(
|
||||
() => Array<string>(),
|
||||
(acc, el) => {
|
||||
if (typeof el.type === 'string') {
|
||||
acc.push(el.type);
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,11 +88,14 @@ describe('discovery', () => {
|
||||
),
|
||||
],
|
||||
collectors: {
|
||||
names: createCollector(Array<string>(), (acc, el) => {
|
||||
if (typeof el.type === 'string') {
|
||||
acc.push(el.type);
|
||||
}
|
||||
}),
|
||||
names: createCollector(
|
||||
() => Array<string>(),
|
||||
(acc, el) => {
|
||||
if (typeof el.type === 'string') {
|
||||
acc.push(el.type);
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export type Collector<Result, Context> = () => {
|
||||
visit(
|
||||
accumulator: Result,
|
||||
element: ReactElement,
|
||||
parent: ReactElement,
|
||||
parent: ReactElement | undefined,
|
||||
context: Context,
|
||||
): Context;
|
||||
};
|
||||
@@ -33,7 +33,7 @@ export type Collector<Result, Context> = () => {
|
||||
* varying methods to discover child nodes and collect data along the way.
|
||||
*/
|
||||
export function traverseElementTree<Results>(options: {
|
||||
root: ReactElement;
|
||||
root: ReactNode;
|
||||
discoverers: Discoverer[];
|
||||
collectors: { [name in keyof Results]: Collector<Results[name], any> };
|
||||
}): Results {
|
||||
@@ -52,14 +52,14 @@ export function traverseElementTree<Results>(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<Results>(options: {
|
||||
}
|
||||
|
||||
export function createCollector<Result, Context>(
|
||||
initialResult: Result,
|
||||
accumulatorFactory: () => Result,
|
||||
visit: ReturnType<Collector<Result, Context>>['visit'],
|
||||
): Collector<Result, Context> {
|
||||
return () => ({ accumulator: initialResult, visit });
|
||||
return () => ({ accumulator: accumulatorFactory(), visit });
|
||||
}
|
||||
|
||||
export function childDiscoverer(element: ReactElement): ReactNode {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { getComponentData } from '../extensions';
|
||||
import { createCollector } from '../extensions/traversal';
|
||||
|
||||
export const pluginCollector = createCollector(
|
||||
new Set<BackstagePlugin>(),
|
||||
() => new Set<BackstagePlugin>(),
|
||||
(acc, node) => {
|
||||
const plugin = getComponentData<BackstagePlugin>(node, 'core.plugin');
|
||||
if (plugin) {
|
||||
|
||||
@@ -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<T extends { [name in string]: string } | never = never>
|
||||
implements ReferencedRoute {
|
||||
constructor(
|
||||
private readonly parent: ConcreteRoute,
|
||||
private readonly config: SubRouteConfig,
|
||||
) {}
|
||||
|
||||
get [routeReference]() {
|
||||
return this;
|
||||
}
|
||||
|
||||
link<Args extends T extends never ? [] : [T]>(...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<Params extends { [param in string]: string }> {
|
||||
constructor(private readonly config: RouteRefConfig<Params>) {}
|
||||
|
||||
get icon() {
|
||||
return this.config.icon;
|
||||
@@ -66,26 +32,24 @@ export class AbsoluteRouteRef implements ConcreteRoute {
|
||||
return this.config.title;
|
||||
}
|
||||
|
||||
createSubRoute<T extends { [name in string]: string } | never = never>(
|
||||
config: SubRouteConfig,
|
||||
) {
|
||||
return new SubRouteRef<T>(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<Params>): RouteRef<Params> {
|
||||
return new AbsoluteRouteRef<Params>(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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<unknown, Node>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<RouteRef, string>(),
|
||||
function getMountPoint(node: ReactElement): RouteRef | undefined {
|
||||
const element: ReactNode = node.props?.element;
|
||||
|
||||
let routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
|
||||
if (!routeRef && isValidElement(element)) {
|
||||
routeRef = getComponentData<RouteRef>(element, 'core.mountPoint');
|
||||
}
|
||||
|
||||
return routeRef;
|
||||
}
|
||||
|
||||
export const routePathCollector = createCollector(
|
||||
() => new Map<RouteRef, string>(),
|
||||
(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<RouteRef>(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<RouteRef>(
|
||||
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<RouteRef, RouteRef | undefined>(),
|
||||
() => new Map<RouteRef, RouteRef | undefined>(),
|
||||
(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<RouteRef>(node, 'core.mountPoint');
|
||||
const routeRef = getMountPoint(node);
|
||||
if (routeRef) {
|
||||
acc.set(routeRef, parentRouteRef);
|
||||
nextParent = routeRef;
|
||||
} else if (isValidElement(element)) {
|
||||
const elementRouteRef = getComponentData<RouteRef>(
|
||||
element,
|
||||
'core.mountPoint',
|
||||
);
|
||||
|
||||
if (elementRouteRef) {
|
||||
acc.set(elementRouteRef, parentRouteRef);
|
||||
nextParent = elementRouteRef;
|
||||
}
|
||||
}
|
||||
|
||||
return nextParent;
|
||||
},
|
||||
);
|
||||
|
||||
export const routeObjectCollector = createCollector(
|
||||
() => Array<BackstageRouteObject>(),
|
||||
(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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<RouteRefConfig<{}>>) => ({
|
||||
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 = <T extends { [name in string]: string }>(props: {
|
||||
name: string;
|
||||
routeRef: RouteRef<T>;
|
||||
params?: T;
|
||||
}) => {
|
||||
try {
|
||||
const routeFunc = useRouteRef(props.routeRef) as RouteFunc<any>;
|
||||
return (
|
||||
<div>
|
||||
Path at {props.name}: {routeFunc(props.params)}
|
||||
</div>
|
||||
);
|
||||
} catch (ex) {
|
||||
return (
|
||||
<div>
|
||||
Error at {props.name}: {ex.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<RoutingProvider
|
||||
routePaths={routePaths}
|
||||
routeParents={routeParents}
|
||||
routeObjects={routeObjects}
|
||||
>
|
||||
{root}
|
||||
</RoutingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('discovery', () => {
|
||||
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension4
|
||||
path="/bar/:id"
|
||||
name="inside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'bleb' }}
|
||||
/>
|
||||
</Extension1>
|
||||
</Routes>
|
||||
<MockRouteSource
|
||||
name="outside"
|
||||
routeRef={ref4}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension4 path="/bar/:id" name="borked" routeRef={ref4} />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 extends { [param in string]: string }> = (
|
||||
...[params]: Params[keyof Params] extends never
|
||||
? readonly []
|
||||
: readonly [Params]
|
||||
) => string;
|
||||
|
||||
class RouteResolver {
|
||||
constructor(
|
||||
private readonly routePaths: Map<AnyRouteRef, string>,
|
||||
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
|
||||
private readonly routeObjects: BackstageRouteObject[],
|
||||
) {}
|
||||
|
||||
resolve<Params extends { [param in string]: string }>(
|
||||
routeRef: RouteRef<Params>,
|
||||
sourceLocation: ReturnType<typeof useLocation>,
|
||||
): RouteFunc<Params> {
|
||||
const match = matchRoutes(this.routeObjects, sourceLocation) ?? [];
|
||||
|
||||
const lastPath = this.routePaths.get(routeRef);
|
||||
if (!lastPath) {
|
||||
throw new Error(`No path for ${routeRef}`);
|
||||
}
|
||||
const targetRefStack = Array<AnyRouteRef>();
|
||||
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> = (...[params]) => {
|
||||
return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`;
|
||||
};
|
||||
return routeFunc;
|
||||
}
|
||||
}
|
||||
|
||||
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
|
||||
|
||||
export function useRouteRef<Params extends { [param in string]: string }>(
|
||||
routeRef: RouteRef<Params>,
|
||||
): RouteFunc<Params> {
|
||||
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<AnyRouteRef, string>;
|
||||
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const RoutingProvider = ({
|
||||
routePaths,
|
||||
routeParents,
|
||||
routeObjects,
|
||||
children,
|
||||
}: ProviderProps) => {
|
||||
const resolver = new RouteResolver(routePaths, routeParents, routeObjects);
|
||||
return (
|
||||
<RoutingContext.Provider value={resolver}>
|
||||
{children}
|
||||
</RoutingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function validateRoutes(
|
||||
routePaths: Map<AnyRouteRef, string>,
|
||||
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
|
||||
) {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Params extends { [param in string]: string } = {}> = {
|
||||
// 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<any>;
|
||||
|
||||
/**
|
||||
* 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 extends { [param in string]: string }> = {
|
||||
params?: Array<keyof Params>;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user