From 0ff4c16b7d5e3ba475e68136e88478dc6b316477 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Apr 2022 17:00:51 +0200 Subject: [PATCH] core-app-api: working v2 route collection implementation Co-authored-by: blam Co-authored-by: Johan Haals Signed-off-by: Patrik Oldsberg --- .../src/routing/collectors.test.tsx | 434 +++++++++++++++++- .../core-app-api/src/routing/collectors.tsx | 148 +++++- 2 files changed, 579 insertions(+), 3 deletions(-) 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;