diff --git a/.changeset/tidy-camels-boil.md b/.changeset/tidy-camels-boil.md
new file mode 100644
index 0000000000..6e59f96f34
--- /dev/null
+++ b/.changeset/tidy-camels-boil.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx
index 9d07be1ffd..7481f6662a 100644
--- a/packages/core-app-api/src/app/AppRouter.tsx
+++ b/packages/core-app-api/src/app/AppRouter.tsx
@@ -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) {
);
}
+
+attachComponentData(AppRouter, 'core.type', 'AppRouter');
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index abb7fe9432..46c2270d0d 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -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');
diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md
index 5175c76ac8..3dd2eb6b22 100644
--- a/packages/core-compat-api/api-report.md
+++ b/packages/core-compat-api/api-report.md
@@ -6,11 +6,18 @@
///
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)
```
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
index 486c373991..85fed83e8c 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
} />
} />
} />
+ } />
,
);
@@ -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' },
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx
index 2b149e6285..8178de2876 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
- const results = new Array();
+ const createdPluginIds = new Map<
+ LegacyBackstagePlugin,
+ Extension[]
+ >();
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>();
+ const detectedExtensions =
+ createdPluginIds.get(plugin) ?? new Array>();
+ 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;
}
diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx
new file mode 100644
index 0000000000..0f1f6145c9
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx
@@ -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(
+ <>
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ >,
+ );
+
+ 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,
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts
new file mode 100644
index 0000000000..59cb136c65
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.ts
@@ -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> {
+ 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],
+ }),
+ ];
+}
diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts
index 2181b2fa9a..e5f61119a3 100644
--- a/packages/core-compat-api/src/index.ts
+++ b/packages/core-compat-api/src/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
+export { convertLegacyApp } from './convertLegacyApp';
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
index 5a325dc0a0..767329da7f 100644
--- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
@@ -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${