From 3f2e59867154c7cd69b1d27de984f06171b15f37 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Sep 2023 16:17:59 +0200 Subject: [PATCH] frontend-app-api: partial support for old routing system Co-authored-by: Camila Belo Co-authored-by: Philipp Hugenroth Signed-off-by: Patrik Oldsberg --- .../extractRouteInfoFromInstanceTree.test.ts | 467 ++++++++++++++++++ .../extractRouteInfoFromInstanceTree.ts | 67 +++ .../frontend-app-api/src/wiring/createApp.tsx | 53 +- 3 files changed, 547 insertions(+), 40 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts create mode 100644 packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts new file mode 100644 index 0000000000..24cf450fcd --- /dev/null +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts @@ -0,0 +1,467 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { RouteRef, createRouteRef } from '@backstage/core-plugin-api'; +import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree'; +import { + coreExtensionData, + createExtensionInput, + createPageExtension, + createPlugin, +} from '@backstage/frontend-plugin-api'; +import { createInstances } from '../wiring/createApp'; +import { MockConfigApi } from '@backstage/test-utils'; + +const ref1 = createRouteRef({ id: 'page1' }); +const ref2 = createRouteRef({ id: 'page2' }); +const ref3 = createRouteRef({ id: 'page3' }); +const ref4 = createRouteRef({ id: 'page4' }); +const ref5 = createRouteRef({ id: 'page5' }); +const refOrder = [ref1, ref2, ref3, ref4, ref5]; + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +// async function routeObj( +// path: string, +// refs: RouteRef[], +// children: any[] = [], +// type: 'mounted' | 'gathered' = 'mounted', +// backstagePlugin?: BackstagePlugin, +// ) { +// return { +// path: path, +// caseSensitive: false, +// element: type, +// routeRefs: new Set(refs), +// children: [ +// { +// path: '*', +// caseSensitive: false, +// element: 'match-all', +// routeRefs: new Set(), +// plugins: new Set(), +// }, +// ...children, +// ], +// plugins: backstagePlugin ? new Set([backstagePlugin]) : new Set(), +// }; +// } + +const emptyLoader = () => Promise.resolve(React.createElement('div')); + +describe('discovery', () => { + it('should collect routes', () => { + const extensions = [ + createPageExtension({ + id: 'nothing', + defaultPath: 'nothing', + loader: emptyLoader, + }), + createPageExtension({ + id: 'page1', + defaultPath: 'foo', + routeRef: ref1, + inputs: { + routes: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: emptyLoader, + }), + createPageExtension({ + id: 'page2', + at: 'page1/routes', + defaultPath: 'bar/:id', + routeRef: ref2, + inputs: { + routes: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: emptyLoader, + }), + createPageExtension({ + id: 'page3', + at: 'page2/routes', + defaultPath: 'baz', + routeRef: ref3, + loader: emptyLoader, + }), + createPageExtension({ + id: 'page4', + defaultPath: 'divsoup', + routeRef: ref4, + loader: emptyLoader, + }), + createPageExtension({ + id: 'page5', + at: 'page1/routes', + defaultPath: 'blop', + routeRef: ref5, + loader: emptyLoader, + }), + ]; + + const { rootInstances } = createInstances({ + config: new MockConfigApi({}), + plugins: [ + createPlugin({ + id: 'test', + extensions, + }), + ], + }); + + const info = extractRouteInfoFromInstanceTree(rootInstances); + + expect(sortedEntries(info.routePaths)).toEqual([ + [ref1, 'foo'], + [ref2, 'bar/:id'], + [ref3, 'baz'], + [ref4, 'divsoup'], + [ref5, 'blop'], + ]); + expect(sortedEntries(info.routeParents)).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, undefined, plugin)], + // undefined, + // plugin, + // ), + // routeObj('blop', [ref5], undefined, undefined, plugin), + // ], + // undefined, + // plugin, + // ), + // routeObj('divsoup', [ref4], undefined, undefined, plugin), + // ]); + }); + + // it('should handle all react router Route patterns', () => { + // const root = ( + // + // + // }> + // + // } /> + // + // + // }> + // } /> + // } /> + // + // + // + // ); + + // const { routing } = traverseElementTree({ + // root, + // 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, undefined], + // [ref4, ref3], + // [ref5, ref3], + // ]); + // }); + + // it('should handle absolute route paths', () => { + // const root = ( + // + // + // }> + // + // } /> + // + // + // }> + // } /> + // } /> + // + // + // + // ); + + // const { routing } = traverseElementTree({ + // root, + // 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, undefined], + // [ref4, ref3], + // [ref5, ref3], + // ]); + // }); + + // it('should use the route aggregator key to bind child routes to the same path', () => { + // const root = ( + // + // + // + // + //
+ // + //
+ // HELLO + //
+ // }> + // + // + // + // + // + // + //
+ //
+ // ); + + // const { routing } = traverseElementTree({ + // root, + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // expect(sortedEntries(routing.paths)).toEqual([ + // [ref1, 'foo'], + // [ref2, 'foo'], + // [ref3, 'bar'], + // [ref4, ''], + // [ref5, ''], + // ]); + // expect(sortedEntries(routing.parents)).toEqual([ + // [ref1, undefined], + // [ref2, undefined], + // [ref3, undefined], + // [ref4, ref3], + // [ref5, ref3], + // ]); + // expect(routing.objects).toEqual([ + // routeObj('foo', [ref1, ref2], [], 'gathered', plugin), + // routeObj( + // 'bar', + // [ref3], + // [routeObj('', [ref4, ref5], [], 'gathered', plugin)], + // undefined, + // plugin, + // ), + // ]); + // }); + + // it('should use the route aggregator but stop when encountering explicit path', () => { + // const root = ( + // + // + // }> + // + // + // + // }> + // } /> + // + // + // + // + // + // + // + // + // ); + + // const { routing } = traverseElementTree({ + // root, + // 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], undefined, undefined, plugin)], + // undefined, + // plugin, + // ), + // ], + // 'gathered', + // plugin, + // ), + // ], + // undefined, + // plugin, + // ), + // ]); + // }); + + // it('should throw when you provide path property on an extension', () => { + // expect(() => { + // traverseElementTree({ + // root: , + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // }).toThrow( + // 'Path property may not be set directly on a routable extension "Extension(Extension1)"', + // ); + // }); + + // it('should throw when element prop is not a string', () => { + // const Div = 'div' as unknown as ComponentType<{ path: boolean }>; + // expect(() => { + // traverseElementTree({ + // root:
, + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // }).toThrow('Element path must be a string at "div"'); + // }); + + // it('should throw when the mount point gatherers have an element prop', () => { + // const AnyAggregationComponent = AggregationComponent as any; + // expect(() => { + // traverseElementTree({ + // root: } />, + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // }).toThrow( + // 'Mount point gatherers may not have an element prop "AggregationComponent"', + // ); + // }); + + // it('should ignore path props within route elements', () => { + // const { routing } = traverseElementTree({ + // root: } />, + // 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 throw when a routable extension does not have a path set', () => { + // expect(() => { + // traverseElementTree({ + // root: , + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // }).toThrow( + // 'Routable extension "Extension(Extension3)" with mount point "routeRef{type=absolute,id=ref3}" must be assigned a path', + // ); + // }); + + // it('should throw when Route elements contain multiple routable extensions', () => { + // expect(() => { + // traverseElementTree({ + // root: ( + // + // + // + // + // } + // /> + // ), + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // routing: routingV2Collector, + // }, + // }); + // }).toThrow( + // 'Route element with path "foo" may not contain multiple routable extensions', + // ); + // }); +}); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts new file mode 100644 index 0000000000..290f5707e9 --- /dev/null +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouteRef } from '@backstage/core-plugin-api'; +import { coreExtensionData } from '@backstage/frontend-plugin-api'; +import { ExtensionInstance } from '../wiring/createExtensionInstance'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { BackstageRouteObject } from '../../../core-app-api/src/routing/types'; + +// Joins a list of paths together, avoiding trailing and duplicate slashes +export function joinPaths(...paths: string[]): string { + const normalized = paths.join('/').replace(/\/\/+/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + return normalized.slice(0, -1); + } + return normalized; +} + +export function extractRouteInfoFromInstanceTree(roots: ExtensionInstance[]): { + routePaths: Map; + routeParents: Map; + routeObjects: BackstageRouteObject[]; +} { + const routePaths = new Map(); + const routeParents = new Map(); + + function visit(current: ExtensionInstance, parent?: RouteRef) { + const routePath = current.getData(coreExtensionData.routePath) ?? ''; + const routeRef = current.getData(coreExtensionData.routeRef); + + if (routeRef) { + const routeRefId = (routeRef as any).id; // TODO: properly + if (routeRefId !== current.id) { + throw new Error( + `Route ref '${routeRefId}' must have the same ID as extension '${current.id}'`, + ); + } + routePaths.set(routeRef, routePath); + routeParents.set(routeRef, parent); + } + + for (const children of current.attachments.values()) { + for (const child of children) { + visit(child, routeRef ?? parent); + } + } + } + + for (const root of roots) { + visit(root); + } + + return { routePaths, routeParents, routeObjects: [] }; +} diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index deb19d919d..4afd1d1a2f 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -74,6 +74,8 @@ import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultCo // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { RoutingProvider as LegacyRoutingProvider } from '../../../core-app-api/src/routing/RoutingProvider'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { apis as defaultApis, components as defaultComponents, @@ -82,6 +84,7 @@ import { import { BrowserRouter, Route } from 'react-router-dom'; import { SidebarItem } from '@backstage/core-components'; import { DarkTheme, LightTheme } from '../extensions/themes'; +import { extractRouteInfoFromInstanceTree } from '../routing/extractRouteInfoFromInstanceTree'; /** @public */ export interface ExtensionTreeNode { @@ -281,7 +284,7 @@ export function createApp(options: { config, }); - const routePaths = extractRouteInfoFromInstanceTree(rootInstances); + const routeInfo = extractRouteInfoFromInstanceTree(rootInstances); const coreInstance = rootInstances.find(({ id }) => id === 'core'); if (!coreInstance) { @@ -304,10 +307,15 @@ export function createApp(options: { - - {/* TODO: set base path using the logic from AppRouter */} - {rootElements} - + + + {/* TODO: set base path using the logic from AppRouter */} + {rootElements} + + @@ -458,38 +466,3 @@ function createApiHolder( return new ApiResolver(factoryRegistry); } - -/** @internal */ -export function extractRouteInfoFromInstanceTree( - roots: ExtensionInstance[], -): Map { - const results = new Map(); - - function visit(current: ExtensionInstance, basePath: string) { - const routePath = current.getData(coreExtensionData.routePath) ?? ''; - const routeRef = current.getData(coreExtensionData.routeRef); - - // TODO: join paths in a more robust way - const fullPath = basePath + routePath; - if (routeRef) { - const routeRefId = (routeRef as any).id; // TODO: properly - if (routeRefId !== current.id) { - throw new Error( - `Route ref '${routeRefId}' must have the same ID as extension '${current.id}'`, - ); - } - results.set(routeRef, fullPath); - } - - for (const children of current.attachments.values()) { - for (const child of children) { - visit(child, fullPath); - } - } - } - - for (const root of roots) { - visit(root, ''); - } - return results; -}