core-api: add collector for route objects and use react-router matching to implement relative routing

Co-authored-by: blam <ben@blam.sh>
This commit is contained in:
Patrik Oldsberg
2020-11-30 17:52:41 +01:00
parent e4782fd3b9
commit 30a115d392
5 changed files with 222 additions and 37 deletions
@@ -31,6 +31,10 @@ export class AbsoluteRouteRef {
get title() {
return this.config.title;
}
toString() {
return `routeRef{path=${this.path}}`;
}
}
export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef {
+35 -1
View File
@@ -15,7 +15,7 @@
*/
import { isValidElement, ReactNode } from 'react';
import { RouteRef } from '../routing/types';
import { BackstageRouteObject, RouteRef } from '../routing/types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
@@ -80,3 +80,37 @@ export const routeParentCollector = createCollector(
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 element: ReactNode = node.props?.element;
let routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
if (!routeRef && isValidElement(element)) {
routeRef = getComponentData<RouteRef>(element, 'core.mountPoint');
}
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;
},
);
+102 -19
View File
@@ -24,7 +24,11 @@ import {
traverseElementTree,
} from '../extensions/traversal';
import { createPlugin } from '../plugin';
import { routePathCollector, routeParentCollector } from './collectors';
import {
routePathCollector,
routeParentCollector,
routeObjectCollector,
} from './collectors';
import { useRouteRef, RoutingProvider, validateRoutes } from './hooks';
import { createRouteRef } from './RouteRef';
import { RouteRef, RouteRefConfig } from './types';
@@ -38,23 +42,31 @@ const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
const plugin = createPlugin({ id: 'my-plugin' });
const ref1 = createRouteRef(mockConfig());
const ref2 = createRouteRef(mockConfig());
const ref3 = createRouteRef(mockConfig());
const ref4 = createRouteRef(mockConfig());
const ref5 = createRouteRef(mockConfig());
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?: Record<string, string>;
}) => {
const routeFunc = useRouteRef(props.routeRef);
return (
<div>
Path at {props.name}: {routeFunc(props.params)}
</div>
);
try {
const routeFunc = useRouteRef(props.routeRef);
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(
@@ -87,17 +99,22 @@ describe('discovery', () => {
</MemoryRouter>
);
const { routePaths, routeParents } = traverseElementTree({
const { routePaths, routeParents, routeObjects } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
},
});
const rendered = render(
<RoutingProvider routePaths={routePaths} routeParents={routeParents}>
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
>
{root}
</RoutingProvider>,
);
@@ -127,17 +144,22 @@ describe('discovery', () => {
</MemoryRouter>
);
const { routePaths, routeParents } = traverseElementTree({
const { routePaths, routeParents, routeObjects } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
},
});
const rendered = render(
<RoutingProvider routePaths={routePaths} routeParents={routeParents}>
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
>
{root}
</RoutingProvider>,
);
@@ -152,27 +174,38 @@ describe('discovery', () => {
it('should handle relative routing within parameterized routePaths', () => {
const root = (
<MemoryRouter initialEntries={['/foo/blob/bar']}>
<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 { routePaths, routeParents } = traverseElementTree({
const { routePaths, routeParents, routeObjects } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
},
});
const rendered = render(
<RoutingProvider routePaths={routePaths} routeParents={routeParents}>
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
>
{root}
</RoutingProvider>,
);
@@ -182,6 +215,56 @@ describe('discovery', () => {
).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 { routePaths, routeParents, routeObjects } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routePaths: routePathCollector,
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
},
});
const rendered = render(
<RoutingProvider
routePaths={routePaths}
routeParents={routeParents}
routeObjects={routeObjects}
>
{root}
</RoutingProvider>,
);
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>
+72 -17
View File
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import React, { createContext, ReactNode, useContext } from 'react';
import { RouteRef } from './types';
import { generatePath, useParams } from 'react-router-dom';
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import { BackstageRouteObject, RouteRef } from './types';
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
export type RouteFunc = (params?: Record<string, string>) => string;
@@ -24,23 +24,71 @@ class RouteResolver {
constructor(
private readonly routePaths: Map<RouteRef, string>,
private readonly routeParents: Map<RouteRef, RouteRef | undefined>,
private readonly routeObjects: BackstageRouteObject[],
) {}
resolve(routeRef: RouteRef, parentParams: Record<string, string>): RouteFunc {
let currentRouteRef: RouteRef | undefined = routeRef;
let fullPath = '';
resolve(
routeRef: RouteRef,
sourceLocation: ReturnType<typeof useLocation>,
): RouteFunc {
const match = matchRoutes(this.routeObjects, sourceLocation) ?? [];
while (currentRouteRef) {
const path = this.routePaths.get(currentRouteRef);
if (!path) {
throw new Error(`No path for ${currentRouteRef}`);
const lastPath = this.routePaths.get(routeRef);
if (!lastPath) {
throw new Error(`No path for ${routeRef}`);
}
const targetRefStack = Array<RouteRef>();
let matchIndex = -1;
for (
let currentRouteRef: RouteRef | undefined = routeRef;
currentRouteRef;
currentRouteRef = this.routeParents.get(currentRouteRef)
) {
matchIndex = match.findIndex(
m => (m.route as BackstageRouteObject).routeRef === currentRouteRef,
);
if (matchIndex !== -1) {
break;
}
fullPath = `${path}${fullPath}`;
currentRouteRef = this.routeParents.get(currentRouteRef);
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
return (params?: Record<string, string>) => {
return generatePath(fullPath, { ...params, ...parentParams });
return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`;
};
}
}
@@ -48,27 +96,34 @@ class RouteResolver {
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
export function useRouteRef(routeRef: RouteRef): RouteFunc {
const sourceLocation = useLocation();
const resolver = useContext(RoutingContext);
const params = useParams();
if (!resolver) {
const routeFunc = useMemo(
() => resolver && resolver.resolve(routeRef, sourceLocation),
[resolver, routeRef, sourceLocation],
);
if (!routeFunc) {
throw new Error('No route resolver found in context');
}
return resolver.resolve(routeRef, params);
return routeFunc;
}
type ProviderProps = {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
children: ReactNode;
};
export const RoutingProvider = ({
routePaths,
routeParents,
routeObjects,
children,
}: ProviderProps) => {
const resolver = new RouteResolver(routePaths, routeParents);
const resolver = new RouteResolver(routePaths, routeParents, routeObjects);
return (
<RoutingContext.Provider value={resolver}>
{children}
+9
View File
@@ -28,3 +28,12 @@ export type RouteRefConfig = {
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: RouteRef;
}