Merge pull request #20341 from backstage/mob/more-routing

frontend-app-api: add support for existing routing system
This commit is contained in:
Patrik Oldsberg
2023-10-05 13:23:11 +02:00
committed by GitHub
7 changed files with 732 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Added support for the existing routing system.
+1
View File
@@ -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",
@@ -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<unknown>[]) {
const plugin = createPlugin({
id: 'test',
extensions,
});
const { rootInstances } = createInstances({
config: new MockConfigApi({}),
plugins: [plugin],
});
return extractRouteInfoFromInstanceTree(rootInstances);
}
function sortedEntries<T>(map: Map<RouteRef, T>): [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',
),
],
),
]);
});
});
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
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<RouteRef, string>();
// 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<RouteRef, RouteRef | undefined>();
// 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<BackstageRouteObject>();
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<RouteRef>(),
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 };
}
@@ -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: {
<ApiProvider apis={apiHolder}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider routePaths={routePaths}>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>{rootElements}</BrowserRouter>
</RoutingProvider>
<LegacyRoutingProvider
{...routeInfo}
routeBindings={new Map(/* TODO */)}
>
<RoutingProvider routePaths={routeInfo.routePaths}>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>{rootElements}</BrowserRouter>
</RoutingProvider>
</LegacyRoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
@@ -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<BackstagePlugin, LegacyBackstagePlugin>(),
);
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<RouteRef, string> {
const results = new Map<RouteRef, string>();
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;
}
@@ -36,6 +36,8 @@ export interface ExtensionInstance {
* Maps input names to the actual instances given to them.
*/
readonly attachments: Map<string, ExtensionInstance[]>;
readonly source?: BackstagePlugin;
}
function resolveInputData(
@@ -141,7 +143,7 @@ export function createExtensionInstance(options: {
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
return extensionData.get(ref.id) as T | undefined;
},
source,
attachments,
};
}
+1
View File
@@ -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