diff --git a/packages/core-app-api/src/routing/collectors.test.tsx b/packages/core-app-api/src/routing/collectors.test.tsx
index bb67966fbd..f22c8d91a6 100644
--- a/packages/core-app-api/src/routing/collectors.test.tsx
+++ b/packages/core-app-api/src/routing/collectors.test.tsx
@@ -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 = [
,
@@ -371,3 +371,433 @@ describe('discovery', () => {
}).toThrow('Mounted routable extension must have a path');
});
});
+
+describe('routingV2Collector', () => {
+ function routeRoot(element: JSX.Element) {
+ return (
+
+ {element}
+
+ );
+ }
+
+ it('should associate path with extension in element prop', () => {
+ const { routing } = traverseElementTree({
+ root: routeRoot(} />),
+ 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(
+
+
+
+ >
+ }
+ />,
+ ),
+ 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(),
+ 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(
+
+
+
+ {[
+ undefined,
+ null,
+
+
+ ,
+ ]}
+
+ >
+ }
+ />,
+ ),
+ 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(
+
+
+ ,
+ ),
+ 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(
+ }>
+ } />
+ ,
+ ),
+ 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(
+ } />}
+ />,
+ ),
+ 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(
+ } />,
+ ),
+ 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(
+
+
+ ,
+ ),
+ 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(
+
+
+ } />
+
+ ,
+ ),
+ 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 = [
+ ,
+ ,
+
+ } />
+
,
+ ];
+
+ const { routing } = traverseElementTree({
+ root: routeRoot(
+ <>
+ }>
+
+
}>
+
+
+ Some text here shouldn't be a problem
+
+ {null}
+
+
} />
+
+
+ {false}
+ {list}
+ {true}
+ {0}
+
+
+
+ } />
+
+ >,
+ ),
+ 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(
+ <>
+ } />
+
+
+
+ }
+ >
+ } />
+
+
+
+
+ >,
+ ),
+ 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(
+ <>
+
+
+
+
+
+ HELLO
+
+ >,
+ ),
+ 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(
+ }>
+
+
+ }>
+
+
+
+
+
+
+
+ ,
+ ),
+ 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,
+ ),
+ ]);
+ });
+});
diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx
index 13220d769a..c5d8072fc8 100644
--- a/packages/core-app-api/src/routing/collectors.tsx
+++ b/packages/core-app-api/src/routing/collectors.tsx
@@ -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(element, 'core.mountPoint');
+ if (routeRef) {
+ const plugin = getComponentData(element, 'core.plugin');
+ entries.push({ routeRef, plugin });
+ }
+
+ collectSubTree(element.props.children, entries);
+ });
+
+ return entries;
+}
+
+export const routingV2Collector = createCollector(
+ () => ({
+ paths: new Map(),
+ parents: new Map(),
+ objects: new Array(),
+ }),
+ (acc, node, parent, ctx?: RoutingV2CollectorContext) => {
+ const path: string | undefined = node.props?.path;
+
+ const mountPoint = getComponentData(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(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;