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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user