Merge pull request #20672 from backstage/mob/app-compat

app-compat-api: add helper for converting entire app
This commit is contained in:
Patrik Oldsberg
2023-10-19 12:57:58 +02:00
committed by GitHub
10 changed files with 320 additions and 17 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
@@ -16,6 +16,7 @@
import React, { useContext, ReactNode, ComponentType, useState } from 'react';
import {
attachComponentData,
ConfigApi,
configApiRef,
IdentityApi,
@@ -186,3 +187,5 @@ export function AppRouter(props: AppRouterProps) {
</RouterComponent>
);
}
attachComponentData(AppRouter, 'core.type', 'AppRouter');
@@ -16,7 +16,11 @@
import React, { ReactNode, useMemo } from 'react';
import { useRoutes } from 'react-router-dom';
import { useApp, useElementFilter } from '@backstage/core-plugin-api';
import {
attachComponentData,
useApp,
useElementFilter,
} from '@backstage/core-plugin-api';
import { isReactRouterBeta } from '../app/isReactRouterBeta';
let warned = false;
@@ -115,3 +119,5 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
return useRoutes(withNotFound);
};
attachComponentData(FlatRoutes, 'core.type', 'FlatRoutes');
+7
View File
@@ -6,11 +6,18 @@
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { default as React_2 } from 'react';
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[];
// @public (undocumented)
export function convertLegacyApp(
rootElement: React_2.JSX.Element,
): (ExtensionOverrides | BackstagePlugin)[];
// (No @packageDocumentation comment for this package)
```
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>,
);
@@ -85,6 +86,12 @@ describe('collectLegacyRoutes', () => {
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
const results = new Array<BackstagePlugin>();
const createdPluginIds = new Map<
LegacyBackstagePlugin,
Extension<unknown>[]
>();
React.Children.forEach(
flatRoutesElement.props.children,
@@ -93,13 +96,18 @@ export function collectLegacyRoutes(
);
const pluginId = plugin.getId();
const path: string = route.props.path;
const detectedExtensions = new Array<Extension<unknown>>();
const detectedExtensions =
createdPluginIds.get(plugin) ?? new Array<Extension<unknown>>();
createdPluginIds.set(plugin, detectedExtensions);
const path: string = route.props.path;
detectedExtensions.push(
createPageExtension({
id: `plugin.${pluginId}.page`,
id: `plugin.${pluginId}.page${
detectedExtensions.length ? detectedExtensions.length + 1 : ''
}`,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
@@ -115,23 +123,20 @@ export function collectLegacyRoutes(
),
}),
);
},
);
detectedExtensions.push(
return Array.from(createdPluginIds).map(([plugin, extensions]) =>
createPlugin({
id: plugin.getId(),
extensions: [
...extensions,
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
),
);
results.push(
createPlugin({
id: plugin.getId(),
extensions: detectedExtensions,
}),
);
},
],
}),
);
return results;
}
@@ -0,0 +1,129 @@
/*
* 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 { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { StackstormPage } from '@backstage/plugin-stackstorm';
import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
import React, { ReactNode } from 'react';
import { Route } from 'react-router-dom';
import { convertLegacyApp } from './convertLegacyApp';
const Root = ({ children }: { children: ReactNode }) => <>{children}</>;
describe('convertLegacyApp', () => {
it('should find and extract root and routes', () => {
const collected = convertLegacyApp(
<>
<div />
<span />
<AppRouter>
<div />
<Root>
<FlatRoutes>
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>
</Root>
</AppRouter>
</>,
);
expect(
collected.map((p: any /* TODO */) => ({
id: p.id,
extensions: p.extensions.map((e: any) => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
defaultConfig: e.configSchema?.parse({}),
})),
})),
).toEqual([
{
id: 'score-card',
extensions: [
{
id: 'plugin.score-card.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'score-board' },
},
{
id: 'apis.plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'stackstorm',
extensions: [
{
id: 'plugin.stackstorm.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'stackstorm' },
},
{
id: 'apis.plugin.stackstorm.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'puppetDb',
extensions: [
{
id: 'plugin.puppetDb.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: undefined,
extensions: [
{
id: 'core.layout',
attachTo: { id: 'core', input: 'root' },
disabled: false,
},
{
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
disabled: true,
},
],
},
]);
});
});
@@ -0,0 +1,139 @@
/*
* 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 React, {
Children,
Fragment,
ReactElement,
ReactNode,
isValidElement,
} from 'react';
import {
BackstagePlugin,
ExtensionOverrides,
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { getComponentData } from '@backstage/core-plugin-api';
import { collectLegacyRoutes } from './collectLegacyRoutes';
function selectChildren(
rootNode: ReactNode,
selector?: (element: ReactElement<{ children?: ReactNode }>) => boolean,
strictError?: string,
): Array<ReactElement<{ children?: ReactNode }>> {
return Children.toArray(rootNode).flatMap(node => {
if (!isValidElement<{ children?: ReactNode }>(node)) {
return [];
}
if (node.type === Fragment) {
return selectChildren(node.props.children, selector, strictError);
}
if (selector === undefined || selector(node)) {
return [node];
}
if (strictError) {
throw new Error(strictError);
}
return selectChildren(node.props.children, selector, strictError);
});
}
/** @public */
export function convertLegacyApp(
rootElement: React.JSX.Element,
): (ExtensionOverrides | BackstagePlugin)[] {
const appRouterEls = selectChildren(
rootElement,
el => getComponentData(el, 'core.type') === 'AppRouter',
);
if (appRouterEls.length !== 1) {
throw new Error(
"Failed to convert legacy app, AppRouter element could not been found. Make sure it's at the top level of the App element tree",
);
}
const rootEls = selectChildren(
appRouterEls[0].props.children,
el =>
Boolean(el.props.children) &&
selectChildren(
el.props.children,
innerEl => getComponentData(innerEl, 'core.type') === 'FlatRoutes',
).length === 1,
);
if (rootEls.length !== 1) {
throw new Error(
"Failed to convert legacy app, Root element containing FlatRoutes could not been found. Make sure it's within the AppRouter element of the App element tree",
);
}
const [rootEl] = rootEls;
const routesEls = selectChildren(
rootEls[0].props.children,
el => getComponentData(el, 'core.type') === 'FlatRoutes',
);
if (routesEls.length !== 1) {
throw new Error(
'Unexpectedly failed to find FlatRoutes in app element tree',
);
}
const [routesEl] = routesEls;
const CoreLayoutOverride = createExtension({
id: 'core.layout',
attachTo: { id: 'core', input: 'root' },
inputs: {
content: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
{ singleton: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
// Clone the root element, this replaces the FlatRoutes declared in the app with out content input
bind({
element: React.cloneElement(rootEl, undefined, inputs.content.element),
});
},
});
const CoreNavOverride = createExtension({
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
output: {},
factory() {},
disabled: true,
});
const collectedRoutes = collectLegacyRoutes(routesEl);
return [
...collectedRoutes,
createExtensionOverrides({
extensions: [CoreLayoutOverride, CoreNavOverride],
}),
];
}
+1
View File
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
export { convertLegacyApp } from './convertLegacyApp';
@@ -63,6 +63,7 @@ function resolveInputs(
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
// TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${