core-compat-api: recursive collectLecacyRoutes

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-11-29 16:55:12 +01:00
parent c8d0f96fd8
commit cf5cc4c8a0
7 changed files with 310 additions and 55 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': minor
---
Discover plugins and routes recursively beneath the root routes in `collectLecacyRoutes`
+13 -12
View File
@@ -22,9 +22,21 @@
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-puppetdb": "workspace:^",
"@backstage/plugin-stackstorm": "workspace:^",
"@oriflame/backstage-plugin-score-card": "^0.7.0",
@@ -33,16 +45,5 @@
},
"files": [
"dist"
],
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0"
}
]
}
@@ -15,11 +15,19 @@
*/
import { FlatRoutes } from '@backstage/core-app-api';
import {
CatalogEntityPage,
CatalogIndexPage,
EntityAboutCard,
EntityLayout,
EntitySwitch,
isKind,
} from '@backstage/plugin-catalog';
import { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { StackstormPage } from '@backstage/plugin-stackstorm';
import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
import React from 'react';
import { Route } from 'react-router-dom';
import React, { Fragment } from 'react';
import { Route, Routes } from 'react-router-dom';
import { collectLegacyRoutes } from './collectLegacyRoutes';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -89,7 +97,7 @@ describe('collectLegacyRoutes', () => {
defaultConfig: { path: 'puppetdb' },
},
{
id: 'page:puppetDb/2',
id: 'page:puppetDb/1',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
@@ -103,4 +111,134 @@ describe('collectLegacyRoutes', () => {
},
]);
});
it('supports recursion into children, including passing through fragments', () => {
const collected = collectLegacyRoutes(
<FlatRoutes>
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
<EntitySwitch>
<EntitySwitch.Case
if={isKind('component')}
children={
<EntityLayout>
<EntityAboutCard variant="gridItem" />
</EntityLayout>
}
/>
<EntitySwitch.Case>
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Fragment>
<Routes>
<Route path="/subthing">
<ScoreBoardPage />
</Route>
</Routes>
</Fragment>
</EntityLayout.Route>
</EntityLayout>
</EntitySwitch.Case>
</EntitySwitch>
</Route>
</FlatRoutes>,
);
expect(
collected.map(p => ({
id: p.id,
extensions: toInternalBackstagePlugin(p).extensions.map(e => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
defaultConfig: e.configSchema?.parse({}),
})),
})),
).toEqual([
{
id: 'catalog',
extensions: [
{
id: 'page:catalog',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'catalog' },
},
{
id: 'page:catalog/1',
attachTo: { id: 'core/routes', input: 'routes' },
defaultConfig: { path: 'catalog/:namespace/:kind/:name' },
disabled: false,
},
{
id: 'routing-shim:catalog/2',
attachTo: {
id: 'page:catalog/1',
input: 'childRoutingShims',
},
defaultConfig: undefined,
disabled: false,
},
{
id: 'routing-shim:catalog/3',
attachTo: {
id: 'routing-shim:catalog/2',
input: 'childRoutingShims',
},
defaultConfig: undefined,
disabled: false,
},
{
id: 'routing-shim:catalog/4',
attachTo: {
id: 'routing-shim:catalog/3',
input: 'childRoutingShims',
},
defaultConfig: undefined,
disabled: false,
},
{
id: 'api:plugin.catalog.service',
attachTo: {
id: 'core',
input: 'apis',
},
defaultConfig: undefined,
disabled: false,
},
{
id: 'api:catalog-react.starred-entities',
attachTo: {
id: 'core',
input: 'apis',
},
defaultConfig: undefined,
disabled: false,
},
{
id: 'api:plugin.catalog.entity-presentation',
attachTo: {
id: 'core',
input: 'apis',
},
defaultConfig: undefined,
disabled: false,
},
],
},
{
id: 'score-card',
extensions: [
{
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
]);
});
});
@@ -14,20 +14,24 @@
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import {
createApiExtension,
createPageExtension,
createPlugin,
BackstagePlugin,
ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { Route, Routes } from 'react-router-dom';
import {
AnyRouteRefParams,
BackstagePlugin as LegacyBackstagePlugin,
RouteRef,
getComponentData,
} from '@backstage/core-plugin-api';
import {
BackstagePlugin,
ExtensionDefinition,
coreExtensionData,
createApiExtension,
createExtension,
createExtensionInput,
createPageExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import React, { Children, ReactNode, isValidElement } from 'react';
import { Route, Routes } from 'react-router-dom';
import { convertLegacyRouteRef } from './convertLegacyRouteRef';
/*
@@ -58,58 +62,150 @@ Existing tasks:
*/
// Creates a shim extension whose purpose is to build up the tree (anchored at
// the root page) of paths/routeRefs so that the app can bind them properly.
function makeRoutingShimExtension(options: {
name: string;
parentExtensionId: string;
routePath?: string;
routeRef?: RouteRef;
}) {
const { name, parentExtensionId, routePath, routeRef } = options;
return createExtension({
kind: 'routing-shim',
name,
attachTo: { id: parentExtensionId, input: 'childRoutingShims' },
inputs: {
childRoutingShims: createExtensionInput({
routePath: coreExtensionData.routePath.optional(),
routeRef: coreExtensionData.routeRef.optional(),
}),
},
output: {
routePath: coreExtensionData.routePath.optional(),
routeRef: coreExtensionData.routeRef.optional(),
},
factory: () => ({
routePath,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
}),
});
}
function visitRouteChildren(options: {
children: ReactNode;
parentExtensionId: string;
context: {
pluginId: string;
extensions: ExtensionDefinition<unknown>[];
getUniqueName: () => string;
discoverPlugin: (plugin: LegacyBackstagePlugin) => void;
};
}): void {
const { children, parentExtensionId, context } = options;
const { pluginId, extensions, getUniqueName, discoverPlugin } = context;
Children.forEach(children, node => {
if (!isValidElement(node)) {
return;
}
const plugin = getComponentData<LegacyBackstagePlugin>(node, 'core.plugin');
const routeRef = getComponentData<RouteRef<AnyRouteRefParams>>(
node,
'core.mountPoint',
);
const routePath: string | undefined = node.props?.path;
if (plugin) {
// We just mark the plugin as discovered, but don't change the context
discoverPlugin(plugin);
}
let nextParentExtensionId = parentExtensionId;
if (routeRef || routePath) {
const nextParentExtensionName = getUniqueName();
nextParentExtensionId = `routing-shim:${pluginId}/${nextParentExtensionName}`;
extensions.push(
makeRoutingShimExtension({
name: nextParentExtensionName,
parentExtensionId,
routePath,
routeRef,
}),
);
}
visitRouteChildren({
children: node.props.children,
parentExtensionId: nextParentExtensionId,
context,
});
});
}
/** @public */
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
const createdPluginIds = new Map<
const pluginExtensions = new Map<
LegacyBackstagePlugin,
ExtensionDefinition<unknown>[]
>();
const getUniqueName = (() => {
let currentIndex = 1;
return () => String(currentIndex++);
})();
const getPluginExtensions = (plugin: LegacyBackstagePlugin) => {
let extensions = pluginExtensions.get(plugin);
if (!extensions) {
extensions = [];
pluginExtensions.set(plugin, extensions);
}
return extensions;
};
React.Children.forEach(
flatRoutesElement.props.children,
(route: ReactNode) => {
if (!React.isValidElement(route)) {
return;
}
// TODO(freben): Handle feature flag and permissions framework wrapper elements
if (route.type !== Route) {
if (!React.isValidElement(route) || route.type !== Route) {
return;
}
const routeElement = route.props.element;
// TODO: to support deeper extension component, e.g. hidden within <RequirePermission>, use https://github.com/backstage/backstage/blob/518a34646b79ec2028cc0ed6bc67d4366c51c4d6/packages/core-app-api/src/routing/collectors.tsx#L69
const path: string | undefined = route.props.path;
const plugin = getComponentData<LegacyBackstagePlugin>(
routeElement,
'core.plugin',
);
if (!plugin) {
return;
}
const routeRef = getComponentData<RouteRef>(
routeElement,
'core.mountPoint',
);
if (!plugin || !path) {
return;
}
const detectedExtensions =
createdPluginIds.get(plugin) ??
new Array<ExtensionDefinition<unknown>>();
createdPluginIds.set(plugin, detectedExtensions);
const extensions = getPluginExtensions(plugin);
const pageExtensionName = extensions.length ? getUniqueName() : undefined;
const pageExtensionId = `page:${plugin.getId()}${
pageExtensionName ? `/${pageExtensionName}` : pageExtensionName
}`;
const path: string = route.props.path;
detectedExtensions.push(
extensions.push(
createPageExtension({
name: detectedExtensions.length
? String(detectedExtensions.length + 1)
: undefined,
name: pageExtensionName,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
inputs: {
childRoutingShims: createExtensionInput({
routePath: coreExtensionData.routePath.optional(),
routeRef: coreExtensionData.routeRef.optional(),
}),
},
loader: async () =>
route.props.children ? (
<Routes>
@@ -122,10 +218,21 @@ export function collectLegacyRoutes(
),
}),
);
visitRouteChildren({
children: route.props.children,
parentExtensionId: pageExtensionId,
context: {
pluginId: plugin.getId(),
extensions,
getUniqueName,
discoverPlugin: getPluginExtensions,
},
});
},
);
return Array.from(createdPluginIds).map(([plugin, extensions]) =>
return Array.from(pluginExtensions).map(([plugin, extensions]) =>
createPlugin({
id: plugin.getId(),
extensions: [
@@ -97,7 +97,7 @@ describe('convertLegacyApp', () => {
defaultConfig: { path: 'puppetdb' },
},
{
id: 'page:puppetDb/2',
id: 'page:puppetDb/1',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
@@ -91,6 +91,7 @@ export function convertLegacyRouteRef(
if (type === 'absolute') {
const legacyRef = ref as LegacyRouteRef;
const legacyRefStr = String(legacyRef);
const newRef = toInternalRouteRef(
createRouteRef<{ [key in string]: string }>({
params: legacyRef.params as string[],
@@ -104,18 +105,19 @@ export function convertLegacyRouteRef(
return newRef.getParams();
},
getDescription() {
return newRef.getDescription();
return legacyRefStr;
},
setId(id: string) {
newRef.setId(id);
},
toString() {
return newRef.toString();
return legacyRefStr;
},
});
}
if (type === 'sub') {
const legacyRef = ref as LegacySubRouteRef;
const legacyRefStr = String(legacyRef);
const newRef = toInternalSubRouteRef(
createSubRouteRef({
path: legacyRef.path,
@@ -133,15 +135,16 @@ export function convertLegacyRouteRef(
return newRef.getParent();
},
getDescription() {
return newRef.getDescription();
return legacyRefStr;
},
toString() {
return newRef.toString();
return legacyRefStr;
},
});
}
if (type === 'external') {
const legacyRef = ref as LegacyExternalRouteRef;
const legacyRefStr = String(legacyRef);
const newRef = toInternalExternalRouteRef(
createExternalRouteRef<{ [key in string]: string }>({
params: legacyRef.params as string[],
@@ -157,13 +160,13 @@ export function convertLegacyRouteRef(
return newRef.getParams();
},
getDescription() {
return newRef.getDescription();
return legacyRefStr;
},
setId(id: string) {
newRef.setId(id);
},
toString() {
return newRef.toString();
return legacyRefStr;
},
});
}
+1
View File
@@ -3856,6 +3856,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-puppetdb": "workspace:^"
"@backstage/plugin-stackstorm": "workspace:^"
"@backstage/version-bridge": "workspace:^"