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${