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:
Patrik Oldsberg
2022-04-07 17:00:51 +02:00
parent 19e40f1120
commit 0ff4c16b7d
2 changed files with 579 additions and 3 deletions
@@ -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;