Merge pull request #20672 from backstage/mob/app-compat
app-compat-api: add helper for converting entire app
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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${
|
||||
|
||||
Reference in New Issue
Block a user