diff --git a/.changeset/unlucky-experts-breathe.md b/.changeset/unlucky-experts-breathe.md new file mode 100644 index 0000000000..018b1826d2 --- /dev/null +++ b/.changeset/unlucky-experts-breathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Added support for the existing routing system. diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 5fb84070d4..477cf8f736 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -42,6 +42,7 @@ "@backstage/plugin-graphiql": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@types/react": "^16.13.1 || ^17.0.0", 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..c64dc7a3b9 --- /dev/null +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts @@ -0,0 +1,528 @@ +/* + * 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 { + BackstagePlugin, + RouteRef, + createRouteRef, +} from '@backstage/core-plugin-api'; +import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree'; +import { + Extension, + coreExtensionData, + createExtension, + createExtensionInput, + 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 createTestExtension(options: { + id: string; + at?: string; + path?: string; + routeRef?: RouteRef; +}) { + return createExtension({ + id: options.id, + at: options.at ?? 'core.routes/children', + output: { + element: coreExtensionData.reactElement, + path: coreExtensionData.routePath.optional(), + routeRef: coreExtensionData.routeRef.optional(), + }, + inputs: { + children: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + factory({ bind }) { + bind({ + path: options.path, + routeRef: options.routeRef, + element: React.createElement('div'), + }); + }, + }); +} + +function routeInfoFromExtensions(extensions: Extension[]) { + const plugin = createPlugin({ + id: 'test', + extensions, + }); + const { rootInstances } = createInstances({ + config: new MockConfigApi({}), + plugins: [plugin], + }); + + return extractRouteInfoFromInstanceTree(rootInstances); +} + +function sortedEntries(map: Map): [RouteRef, T][] { + return Array.from(map).sort( + ([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b), + ); +} + +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(), + }; +} + +describe('discovery', () => { + it('should collect routes', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'nothing', + path: 'nothing', + }), + createTestExtension({ + id: 'page1', + path: 'foo', + routeRef: ref1, + }), + createTestExtension({ + id: 'page2', + at: 'page1/children', + path: 'bar/:id', + routeRef: ref2, + }), + createTestExtension({ + id: 'page3', + at: 'page2/children', + path: 'baz', + routeRef: ref3, + }), + createTestExtension({ + id: 'page4', + path: 'divsoup', + routeRef: ref4, + }), + createTestExtension({ + id: 'page5', + at: 'page1/children', + path: 'blop', + routeRef: ref5, + }), + ]); + + 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(info.routeObjects).toEqual([ + routeObj('nothing', []), + routeObj( + 'foo', + [ref1], + [ + routeObj( + 'bar/:id', + [ref2], + [routeObj('baz', [ref3], undefined, undefined, expect.any(Object))], + undefined, + expect.any(Object), + ), + routeObj('blop', [ref5], undefined, undefined, expect.any(Object)), + ], + undefined, + expect.any(Object), + ), + routeObj('divsoup', [ref4], undefined, undefined, expect.any(Object)), + ]); + }); + + it('should handle all react router Route patterns', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'page1', + path: 'foo', + routeRef: ref1, + }), + createTestExtension({ + id: 'page2', + at: 'page1/children', + path: 'bar/:id', + routeRef: ref2, + }), + createTestExtension({ + id: 'page3', + path: 'baz', + routeRef: ref3, + }), + createTestExtension({ + id: 'page4', + at: 'page3/children', + path: 'divsoup', + routeRef: ref4, + }), + createTestExtension({ + id: 'page5', + at: 'page3/children', + path: 'blop', + routeRef: ref5, + }), + ]); + + 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, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should strip leading slashes in route paths', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'page1', + path: '/foo', + routeRef: ref1, + }), + createTestExtension({ + id: 'page2', + at: 'page1/children', + path: '/bar/:id', + routeRef: ref2, + }), + createTestExtension({ + id: 'page3', + path: '/baz', + routeRef: ref3, + }), + createTestExtension({ + id: 'page4', + at: 'page3/children', + path: '/divsoup', + routeRef: ref4, + }), + createTestExtension({ + id: 'page5', + at: 'page3/children', + path: '/blop', + routeRef: ref5, + }), + ]); + + 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, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + }); + + it('should use the route aggregator key to bind child routes to the same path', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'foo', + path: 'foo', + }), + createTestExtension({ + id: 'page1', + at: 'foo/children', + routeRef: ref1, + }), + createTestExtension({ + id: 'fooChild', + at: 'foo/children', + }), + createTestExtension({ + id: 'page2', + at: 'fooChild/children', + routeRef: ref2, + }), + createTestExtension({ + id: 'fooEmpty', + }), + createTestExtension({ + id: 'page3', + path: 'bar', + routeRef: ref3, + }), + createTestExtension({ + id: 'page3Child', + at: 'page3/children', + path: '', + }), + createTestExtension({ + id: 'page4', + at: 'page3Child/children', + routeRef: ref4, + }), + createTestExtension({ + id: 'page5', + at: 'page4/children', + routeRef: ref5, + }), + ]); + + expect(sortedEntries(info.routePaths)).toEqual([ + [ref1, 'foo'], + [ref2, 'foo'], + [ref3, 'bar'], + [ref4, ''], + [ref5, ''], + ]); + expect(sortedEntries(info.routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, undefined], + [ref4, ref3], + [ref5, ref3], + ]); + expect(info.routeObjects).toEqual([ + routeObj('foo', [ref1, ref2], [], 'mounted', expect.any(Object)), + routeObj( + 'bar', + [ref3], + [routeObj('', [ref4, ref5], [], 'mounted', expect.any(Object))], + 'mounted', + expect.any(Object), + ), + ]); + }); + + it('should use the route aggregator but stop when encountering explicit path', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'page1', + path: 'foo', + routeRef: ref1, + }), + createTestExtension({ + id: 'page1Child', + at: 'page1/children', + path: 'bar', + }), + createTestExtension({ + id: 'page2', + at: 'page1Child/children', + routeRef: ref2, + }), + createTestExtension({ + id: 'page3', + at: 'page2/children', + path: 'baz', + routeRef: ref3, + }), + createTestExtension({ + id: 'page4', + at: 'page3/children', + path: '/blop', + routeRef: ref4, + }), + createTestExtension({ + id: 'page5', + at: 'page2/children', + routeRef: ref5, + }), + ]); + + expect(sortedEntries(info.routePaths)).toEqual([ + [ref1, 'foo'], + [ref2, 'bar'], + [ref3, 'baz'], + [ref4, 'blop'], + [ref5, 'bar'], + ]); + expect(sortedEntries(info.routeParents)).toEqual([ + [ref1, undefined], + [ref2, ref1], + [ref3, ref2], + [ref4, ref3], + [ref5, ref1], + ]); + expect(info.routeObjects).toEqual([ + routeObj( + 'foo', + [ref1], + [ + routeObj( + 'bar', + [ref2, ref5], + [ + routeObj( + 'baz', + [ref3], + [ + routeObj( + 'blop', + [ref4], + undefined, + undefined, + expect.any(Object), + ), + ], + undefined, + expect.any(Object), + ), + ], + 'mounted', + expect.any(Object), + ), + ], + undefined, + expect.any(Object), + ), + ]); + }); + + it('should account for loose route paths', () => { + const info = routeInfoFromExtensions([ + createTestExtension({ + id: 'r', + path: 'r', + }), + createTestExtension({ + id: 'page1', + at: 'r/children', + path: 'x', + routeRef: ref1, + }), + createTestExtension({ + id: 'y', + path: 'y', + at: 'r/children', + }), + createTestExtension({ + id: 'page2', + at: 'y/children', + path: '1', + routeRef: ref2, + }), + createTestExtension({ + id: 'page3', + at: 'page2/children', + path: 'a', + routeRef: ref3, + }), + createTestExtension({ + id: 'page4', + at: 'page2/children', + path: 'b', + routeRef: ref4, + }), + ]); + + expect(sortedEntries(info.routePaths)).toEqual([ + [ref1, 'r/x'], + [ref2, 'r/y/1'], + [ref3, 'a'], + [ref4, 'b'], + ]); + expect(sortedEntries(info.routeParents)).toEqual([ + [ref1, undefined], + [ref2, undefined], + [ref3, ref2], + [ref4, ref2], + ]); + expect(info.routeObjects).toEqual([ + routeObj( + 'r', + [], + [ + routeObj('x', [ref1], [], 'mounted', expect.any(Object)), + routeObj( + 'y', + [], + [ + routeObj( + '1', + [ref2], + [ + routeObj( + 'a', + [ref3], + undefined, + 'mounted', + expect.any(Object), + ), + routeObj( + 'b', + [ref4], + undefined, + 'mounted', + expect.any(Object), + ), + ], + 'mounted', + expect.any(Object), + ), + ], + 'mounted', + ), + ], + ), + ]); + }); +}); 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..30305b0d2c --- /dev/null +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts @@ -0,0 +1,159 @@ +/* + * 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'; +import { toLegacyPlugin } from '../wiring/createApp'; + +// We always add a child that matches all subroutes but without any route refs. This makes +// sure that we're always able to match each route no matter how deep the navigation goes. +// The route resolver then takes care of selecting the most specific match in order to find +// mount points that are as deep in the routing tree as possible. +export const MATCH_ALL_ROUTE: BackstageRouteObject = { + caseSensitive: false, + path: '*', + element: 'match-all', // These elements aren't used, so we add in a bit of debug information + routeRefs: new Set(), + plugins: new Set(), +}; + +// 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[]; +} { + // This tracks the route path for each route ref, the value is the route path relative to the parent ref + const routePaths = new Map(); + // This tracks the parents of each route ref. To find the full path of any route ref you traverse + // upwards in this tree and substitute each route ref for its route path along then way. + const routeParents = new Map(); + // This route object tree is passed to react-router in order to be able to look up the current route + // ref or extension/source based on our current location. + const routeObjects = new Array(); + + function visit( + current: ExtensionInstance, + collectedPath?: string, + foundRefForCollectedPath: boolean = false, + parentRef?: RouteRef, + candidateParentRef?: RouteRef, + parentObj?: BackstageRouteObject, + ) { + const routePath = current + .getData(coreExtensionData.routePath) + ?.replace(/^\//, ''); + const routeRef = current.getData(coreExtensionData.routeRef); + const parentChildren = parentObj?.children ?? routeObjects; + let currentObj = parentObj; + + let newCollectedPath = collectedPath; + let newFoundRefForCollectedPath = foundRefForCollectedPath; + + let newParentRef = parentRef; + let newCandidateParentRef = candidateParentRef; + + // Whenever a route path is encountered, a new node is created in the routing tree. + if (routePath !== undefined) { + currentObj = { + path: routePath, + element: 'mounted', + routeRefs: new Set(), + caseSensitive: false, + children: [MATCH_ALL_ROUTE], + plugins: new Set(), + }; + parentChildren.push(currentObj); + + // Each route path that we discover creates a new node in the routing tree, at that point + // we also switch out our candidate parent ref to be the active one. + newParentRef = candidateParentRef; + newCandidateParentRef = undefined; + + // We need to collect and concatenate route paths until the path has been assigned a route ref: + // Once we find a route ref the collection starts over from an empty path, that way each route + // path assignment only contains the diff from the parent ref. + if (newFoundRefForCollectedPath) { + newCollectedPath = routePath; + newFoundRefForCollectedPath = false; + } else { + newCollectedPath = collectedPath + ? joinPaths(collectedPath, routePath) + : routePath; + } + } + + // Whenever a route ref is encountered, we need to give it a route path and position in the ref tree. + 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}'`, + ); + } + + // The first route ref we find after encountering a route path is selected to be used as the + // parent ref further down the tree. We don't start using this candidate ref until we encounter + // another route path though, at which point we repeat the process and select another candidate. + if (!newCandidateParentRef) { + newCandidateParentRef = routeRef; + } + + // Check if we've encountered any route paths since the closest route ref, in that case we assign + // that path to this and following route refs until we encounter another route path. + if (newCollectedPath !== undefined) { + routePaths.set(routeRef, newCollectedPath); + newFoundRefForCollectedPath = true; + } + + routeParents.set(routeRef, newParentRef); + currentObj?.routeRefs.add(routeRef); + if (current.source) { + currentObj?.plugins.add(toLegacyPlugin(current.source)); + } + } + + for (const children of current.attachments.values()) { + for (const child of children) { + visit( + child, + newCollectedPath, + newFoundRefForCollectedPath, + newParentRef, + newCandidateParentRef, + currentObj, + ); + } + } + } + + 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..540b47849f 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,8 @@ 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'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; /** @public */ export interface ExtensionTreeNode { @@ -281,7 +285,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 +308,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} + + @@ -328,25 +337,40 @@ export function createApp(options: { }; } -function toLegacyPlugin(plugin: BackstagePlugin): LegacyBackstagePlugin { +// Make sure that we only convert each new plugin instance to its legacy equivalent once +const legacyPluginStore = getOrCreateGlobalSingleton( + 'legacy-plugin-compatibility-store', + () => new WeakMap(), +); + +export function toLegacyPlugin(plugin: BackstagePlugin): LegacyBackstagePlugin { + let legacy = legacyPluginStore.get(plugin); + if (legacy) { + return legacy; + } + const errorMsg = 'Not implemented in legacy plugin compatibility layer'; const notImplemented = () => { throw new Error(errorMsg); }; - return { + + legacy = { getId(): string { return plugin.id; }, - get routes(): never { - throw new Error(errorMsg); + get routes() { + return {}; }, - get externalRoutes(): never { - throw new Error(errorMsg); + get externalRoutes() { + return {}; }, getApis: notImplemented, getFeatureFlags: notImplemented, provide: notImplemented, }; + + legacyPluginStore.set(plugin, legacy); + return legacy; } function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext { @@ -458,38 +482,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; -} diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts index afa3cbfefc..18551eb8da 100644 --- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts +++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts @@ -36,6 +36,8 @@ export interface ExtensionInstance { * Maps input names to the actual instances given to them. */ readonly attachments: Map; + + readonly source?: BackstagePlugin; } function resolveInputData( @@ -141,7 +143,7 @@ export function createExtensionInstance(options: { getData(ref: ExtensionDataRef): T | undefined { return extensionData.get(ref.id) as T | undefined; }, - + source, attachments, }; } diff --git a/yarn.lock b/yarn.lock index 96fd6b3286..acc15f7f72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4313,6 +4313,7 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/version-bridge": "workspace:^" "@material-ui/core": ^4.12.4 "@material-ui/icons": ^4.11.3 "@testing-library/jest-dom": ^5.10.1