core-app-api: working v2 route collection implementation
Co-authored-by: blam <ben@blam.sh> Co-authored-by: Johan Haals <johan.haals@gmail.com> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { routingV1Collector } from './collectors';
|
||||
import { routingV1Collector, routingV2Collector } from './collectors';
|
||||
|
||||
import {
|
||||
traverseElementTree,
|
||||
@@ -120,7 +120,7 @@ function routeObj(
|
||||
};
|
||||
}
|
||||
|
||||
describe('discovery', () => {
|
||||
describe('routingV1Collector', () => {
|
||||
it('should collect routes', () => {
|
||||
const list = [
|
||||
<div key={0} />,
|
||||
@@ -371,3 +371,433 @@ describe('discovery', () => {
|
||||
}).toThrow('Mounted routable extension must have a path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routingV2Collector', () => {
|
||||
function routeRoot(element: JSX.Element) {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Routes>{element}</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
it('should associate path with extension in element prop', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(<Route path="/foo" element={<Extension1 />} />),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sortedEntries(routing.paths)).toEqual([[ref1, '/foo']]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([[ref1, undefined]]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj('/foo', [ref1], [], undefined, plugin),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not allow multiple extensions within the same element prop', () => {
|
||||
expect(() =>
|
||||
traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route
|
||||
path="/foo"
|
||||
element={
|
||||
<>
|
||||
<Extension1 />
|
||||
<Extension2 />
|
||||
</>
|
||||
}
|
||||
/>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
}),
|
||||
).toThrow('Route element may not contain multiple routable extensions');
|
||||
});
|
||||
|
||||
it('should not support inline path', () => {
|
||||
expect(() =>
|
||||
traverseElementTree({
|
||||
root: routeRoot(<Extension1 path="/foo" />),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
}),
|
||||
).toThrow('Path property may not be set directly on a routable extension');
|
||||
});
|
||||
|
||||
it('should associate the path with extensions deep in the element prop', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route
|
||||
path="/foo"
|
||||
element={
|
||||
<>
|
||||
<div>
|
||||
<span />
|
||||
{[
|
||||
undefined,
|
||||
null,
|
||||
<main>
|
||||
<Extension1 />
|
||||
</main>,
|
||||
]}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sortedEntries(routing.paths)).toEqual([[ref1, '/foo']]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([[ref1, undefined]]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj('/foo', [ref1], [], undefined, plugin),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not associate path with extension in children prop', () => {
|
||||
expect(() =>
|
||||
traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route path="/foo">
|
||||
<Extension1 />
|
||||
</Route>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
}),
|
||||
).toThrow('Routable extension must be assigned a path');
|
||||
});
|
||||
|
||||
it('should assign parent for extension in element prop', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route path="/foo" element={<Extension1 />}>
|
||||
<Route path="/bar" element={<Extension2 />} />
|
||||
</Route>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/bar'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, ref1],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj(
|
||||
'/foo',
|
||||
[ref1],
|
||||
[routeObj('/bar', [ref2], [], undefined, plugin)],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not allow paths within element props', () => {
|
||||
expect(() => {
|
||||
traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route
|
||||
path="."
|
||||
element={<Route path="/foo" element={<Extension1 />} />}
|
||||
/>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
}).toThrow('Elements within the element prop tree may not contain paths');
|
||||
});
|
||||
|
||||
it('should not allow extensions within the element prop to have path props either', () => {
|
||||
expect(() => {
|
||||
traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route path="." element={<Extension1 path="/foo" />} />,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
}).toThrow('Elements within the element prop tree may not contain paths');
|
||||
});
|
||||
|
||||
it('should gather extension', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<AggregationComponent path="/foo">
|
||||
<Extension1 />
|
||||
</AggregationComponent>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sortedEntries(routing.paths)).toEqual([[ref1, '/foo']]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([[ref1, undefined]]);
|
||||
expect(routing.objects).toEqual([routeObj('/foo', [ref1], [], 'gathered')]);
|
||||
});
|
||||
|
||||
it('should reset gathering if path prop is encountered', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<AggregationComponent path="/foo">
|
||||
<Extension1>
|
||||
<Route path="/bar" element={<Extension2 />} />
|
||||
</Extension1>
|
||||
</AggregationComponent>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/bar'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, ref1],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj(
|
||||
'/foo',
|
||||
[ref1],
|
||||
[routeObj('/bar', [ref2], [], undefined, plugin)],
|
||||
'gathered',
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should collect routes defined with different patterns', () => {
|
||||
const list = [
|
||||
<div key={0} />,
|
||||
<div key={1} />,
|
||||
<div key={3}>
|
||||
<Route path="/blop" element={<Extension5 />} />
|
||||
</div>,
|
||||
];
|
||||
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<>
|
||||
<Route path="/foo" element={<Extension1 />}>
|
||||
<div>
|
||||
<Route path="/bar/:id" element={<Extension2 />}>
|
||||
<div>
|
||||
<div />
|
||||
Some text here shouldn't be a problem
|
||||
<div />
|
||||
{null}
|
||||
<div />
|
||||
<Route path="/baz" element={<Extension3 />} />
|
||||
</div>
|
||||
</Route>
|
||||
{false}
|
||||
{list}
|
||||
{true}
|
||||
{0}
|
||||
</div>
|
||||
</Route>
|
||||
<div>
|
||||
<Route path="/divsoup" element={<Extension4 />} />
|
||||
</div>
|
||||
</>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/bar/:id'],
|
||||
[ref3, '/baz'],
|
||||
[ref4, '/divsoup'],
|
||||
[ref5, '/blop'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, ref1],
|
||||
[ref3, ref2],
|
||||
[ref4, undefined],
|
||||
[ref5, ref1],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj(
|
||||
'/foo',
|
||||
[ref1],
|
||||
[
|
||||
routeObj(
|
||||
'/bar/:id',
|
||||
[ref2],
|
||||
[routeObj('/baz', [ref3], [], undefined, plugin)],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
routeObj('/blop', [ref5], [], undefined, plugin),
|
||||
],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
routeObj('/divsoup', [ref4], undefined, undefined, plugin),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle a subset of react router Route patterns', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<>
|
||||
<Route path="/foo" element={<Extension1 />} />
|
||||
<Route
|
||||
path="/baz"
|
||||
element={
|
||||
<div>
|
||||
<Extension2 />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Route path="/child" element={<Extension3 />} />
|
||||
<AggregationComponent path="/collected">
|
||||
<Extension4 />
|
||||
</AggregationComponent>
|
||||
</Route>
|
||||
</>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/baz'],
|
||||
[ref3, '/child'],
|
||||
[ref4, '/collected'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, undefined],
|
||||
[ref3, ref2],
|
||||
[ref4, ref2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should bind child routes to the same path with a gatherer flag', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<>
|
||||
<AggregationComponent path="/foo">
|
||||
<Extension1 />
|
||||
<div>
|
||||
<Extension2 />
|
||||
</div>
|
||||
HELLO
|
||||
</AggregationComponent>
|
||||
</>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/foo'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, undefined],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj('/foo', [ref1, ref2], [], 'gathered'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should gather routes but stop when encountering explicit path', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: routeRoot(
|
||||
<Route path="/foo" element={<Extension1 />}>
|
||||
<AggregationComponent path="/bar">
|
||||
<Extension2>
|
||||
<Route path="/baz" element={<Extension3 />}>
|
||||
<AggregationComponent path="/blop">
|
||||
<Extension4 />
|
||||
</AggregationComponent>
|
||||
</Route>
|
||||
<Extension5 />
|
||||
</Extension2>
|
||||
</AggregationComponent>
|
||||
</Route>,
|
||||
),
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
expect(sortedEntries(routing.paths)).toEqual([
|
||||
[ref1, '/foo'],
|
||||
[ref2, '/bar'],
|
||||
[ref3, '/baz'],
|
||||
[ref4, '/blop'],
|
||||
[ref5, '/bar'],
|
||||
]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([
|
||||
[ref1, undefined],
|
||||
[ref2, ref1],
|
||||
[ref3, ref2],
|
||||
[ref4, ref3],
|
||||
[ref5, ref1],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj(
|
||||
'/foo',
|
||||
[ref1],
|
||||
[
|
||||
routeObj(
|
||||
'/bar',
|
||||
[ref2, ref5],
|
||||
[
|
||||
routeObj(
|
||||
'/baz',
|
||||
[ref3],
|
||||
[routeObj('/blop', [ref4], [], 'gathered')],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
],
|
||||
'gathered',
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
getComponentData,
|
||||
BackstagePlugin,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { isValidElement } from 'react';
|
||||
import { isValidElement, ReactNode, Children } from 'react';
|
||||
import { BackstageRouteObject } from './types';
|
||||
import { createCollector } from '../extensions/traversal';
|
||||
import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
|
||||
@@ -35,6 +35,152 @@ export const MATCH_ALL_ROUTE: BackstageRouteObject = {
|
||||
routeRefs: new Set(),
|
||||
};
|
||||
|
||||
interface RoutingV2CollectorContext {
|
||||
routeRef?: RouteRef;
|
||||
gatherPath?: string;
|
||||
gatherRouteRef?: RouteRef;
|
||||
obj?: BackstageRouteObject;
|
||||
isElementAncestor?: boolean;
|
||||
}
|
||||
|
||||
function collectSubTree(
|
||||
node: ReactNode,
|
||||
entries = new Array<{ routeRef: RouteRef; plugin?: BackstagePlugin }>(),
|
||||
) {
|
||||
Children.forEach(node, element => {
|
||||
if (!isValidElement(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.props.path) {
|
||||
throw new Error(
|
||||
'Elements within the element prop tree may not contain paths',
|
||||
);
|
||||
}
|
||||
|
||||
const routeRef = getComponentData<RouteRef>(element, 'core.mountPoint');
|
||||
if (routeRef) {
|
||||
const plugin = getComponentData<BackstagePlugin>(element, 'core.plugin');
|
||||
entries.push({ routeRef, plugin });
|
||||
}
|
||||
|
||||
collectSubTree(element.props.children, entries);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export const routingV2Collector = createCollector(
|
||||
() => ({
|
||||
paths: new Map<RouteRef, string>(),
|
||||
parents: new Map<RouteRef, RouteRef | undefined>(),
|
||||
objects: new Array<BackstageRouteObject>(),
|
||||
}),
|
||||
(acc, node, parent, ctx?: RoutingV2CollectorContext) => {
|
||||
const path: string | undefined = node.props?.path;
|
||||
|
||||
const mountPoint = getComponentData<RouteRef>(node, 'core.mountPoint');
|
||||
if (mountPoint) {
|
||||
if (path) {
|
||||
throw new Error(
|
||||
'Path property may not be set directly on a routable extension',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in an element prop, ignore everything
|
||||
if (ctx?.isElementAncestor) {
|
||||
return ctx;
|
||||
}
|
||||
// Start ignoring everything if we enter an element prop
|
||||
if (parent?.props.element === node) {
|
||||
return { ...ctx, isElementAncestor: true };
|
||||
}
|
||||
|
||||
let currentObj = ctx?.obj;
|
||||
const parentChildren = currentObj?.children ?? acc.objects;
|
||||
|
||||
if (path) {
|
||||
const elementProp = node.props.element;
|
||||
|
||||
if (getComponentData<boolean>(node, 'core.gatherMountPoints')) {
|
||||
if (elementProp) {
|
||||
throw new Error('Mount point gatherers may not have an element prop');
|
||||
}
|
||||
|
||||
currentObj = {
|
||||
path,
|
||||
element: 'gathered',
|
||||
routeRefs: new Set(),
|
||||
caseSensitive: Boolean(node.props?.caseSensitive),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin: undefined,
|
||||
};
|
||||
parentChildren.push(currentObj);
|
||||
|
||||
return {
|
||||
obj: currentObj,
|
||||
gatherPath: path,
|
||||
routeRef: ctx?.routeRef,
|
||||
gatherRouteRef: ctx?.routeRef,
|
||||
};
|
||||
}
|
||||
|
||||
if (elementProp) {
|
||||
const [{ routeRef, plugin }, ...others] = collectSubTree(elementProp);
|
||||
if (others.length > 0) {
|
||||
throw new Error(
|
||||
'Route element may not contain multiple routable extensions',
|
||||
);
|
||||
}
|
||||
|
||||
currentObj = {
|
||||
path,
|
||||
element: 'mounted',
|
||||
routeRefs: new Set([routeRef]),
|
||||
caseSensitive: Boolean(node.props?.caseSensitive),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin,
|
||||
};
|
||||
parentChildren.push(currentObj);
|
||||
acc.parents.set(routeRef, ctx?.routeRef);
|
||||
acc.paths.set(routeRef, path);
|
||||
|
||||
return {
|
||||
obj: currentObj,
|
||||
routeRef: routeRef ?? ctx?.routeRef,
|
||||
gatherPath: path,
|
||||
gatherRouteRef: ctx?.gatherRouteRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (mountPoint) {
|
||||
if (!ctx?.gatherPath) {
|
||||
throw new Error('Routable extension must be assigned a path');
|
||||
}
|
||||
|
||||
ctx?.obj?.routeRefs.add(mountPoint);
|
||||
acc.parents.set(mountPoint, ctx?.gatherRouteRef);
|
||||
acc.paths.set(mountPoint, ctx.gatherPath);
|
||||
|
||||
return {
|
||||
obj: currentObj,
|
||||
routeRef: mountPoint,
|
||||
gatherPath: ctx?.gatherPath,
|
||||
gatherRouteRef: ctx?.gatherRouteRef,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
obj: currentObj,
|
||||
routeRef: ctx?.routeRef,
|
||||
gatherPath: ctx?.gatherPath,
|
||||
gatherRouteRef: ctx?.gatherRouteRef,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
interface RoutingV1CollectorContext {
|
||||
path?: string;
|
||||
routeRef?: RouteRef;
|
||||
|
||||
Reference in New Issue
Block a user