frontend-app-api: partial support for old routing system

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Philipp Hugenroth <philipph@spotify.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-29 16:17:59 +02:00
parent 3b500cdcd1
commit 3f2e598671
3 changed files with 547 additions and 40 deletions
@@ -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<T>(map: Map<RouteRef, T>): [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 = (
// <MemoryRouter>
// <Routes>
// <Route path="foo" element={<Extension1 />}>
// <Routes>
// <Route path="bar/:id" element={<Extension2 />} />
// </Routes>
// </Route>
// <Route path="baz" element={<Extension3 />}>
// <Route path="divsoup" element={<Extension4 />} />
// <Route path="blop" element={<Extension5 />} />
// </Route>
// </Routes>
// </MemoryRouter>
// );
// 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 = (
// <MemoryRouter>
// <Routes>
// <Route path="/foo" element={<Extension1 />}>
// <Routes>
// <Route path="/bar/:id" element={<Extension2 />} />
// </Routes>
// </Route>
// <Route path="/baz" element={<Extension3 />}>
// <Route path="/divsoup" element={<Extension4 />} />
// <Route path="/blop" element={<Extension5 />} />
// </Route>
// </Routes>
// </MemoryRouter>
// );
// 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 = (
// <MemoryRouter>
// <Routes>
// <AggregationComponent path="foo">
// <Extension1 />
// <div>
// <Extension2 />
// </div>
// HELLO
// </AggregationComponent>
// <Route path="bar" element={<Extension3 />}>
// <AggregationComponent path="">
// <Extension4>
// <Extension5 />
// </Extension4>
// </AggregationComponent>
// </Route>
// </Routes>
// </MemoryRouter>
// );
// 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 = (
// <MemoryRouter>
// <Routes>
// <Route path="foo" element={<Extension1 />}>
// <AggregationComponent path="/bar">
// <Extension2>
// <Routes>
// <Route path="baz" element={<Extension3 />}>
// <Route path="/blop" element={<Extension4 />} />
// </Route>
// </Routes>
// <Extension5 />
// </Extension2>
// </AggregationComponent>
// </Route>
// </Routes>
// </MemoryRouter>
// );
// 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: <Extension1 path="/foo" />,
// 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: <Div path />,
// 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: <AnyAggregationComponent path="test" element={<Extension3 />} />,
// 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: <Route path="foo" element={<Extension1 path="bar" />} />,
// 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: <Extension3 />,
// 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: (
// <Route
// path="foo"
// element={
// <>
// <Extension1 />
// <Extension2 />
// </>
// }
// />
// ),
// discoverers: [childDiscoverer, routeElementDiscoverer],
// collectors: {
// routing: routingV2Collector,
// },
// });
// }).toThrow(
// 'Route element with path "foo" may not contain multiple routable extensions',
// );
// });
});
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
} {
const routePaths = new Map<RouteRef, string>();
const routeParents = new Map<RouteRef, RouteRef | undefined>();
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: [] };
}
@@ -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: {
<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>
@@ -458,38 +466,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;
}