From 337f8a6ca2df501ceb618b58786f3aa437910ae5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 7 Mar 2025 13:28:05 +0100 Subject: [PATCH 1/8] core-compat-api: add initial version of convertLegacyEntityPage Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/package.json | 1 + .../src/convertLegacyEntityPage.ts | 246 ++++++++++++++++++ packages/core-compat-api/src/index.ts | 1 + yarn.lock | 1 + 4 files changed, 249 insertions(+) create mode 100644 packages/core-compat-api/src/convertLegacyEntityPage.ts diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index d49cb9bdaa..15a10609d7 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -33,6 +33,7 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-catalog-react": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21" }, diff --git a/packages/core-compat-api/src/convertLegacyEntityPage.ts b/packages/core-compat-api/src/convertLegacyEntityPage.ts new file mode 100644 index 0000000000..7df99240f1 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyEntityPage.ts @@ -0,0 +1,246 @@ +/* + * Copyright 2025 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 { ApiHolder, getComponentData } from '@backstage/core-plugin-api'; +import { + createFrontendPlugin, + ExtensionDefinition, + FrontendModule, + FrontendPlugin, +} from '@backstage/frontend-plugin-api'; +import React from 'react'; +import { + EntityCardBlueprint, + EntityContentBlueprint, +} from '@backstage/plugin-catalog-react/alpha'; + +const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch'; +const ENTITY_ROUTE_KEY = 'plugin.catalog.entityLayoutRoute'; + +// Placeholder to make sure internal types here are consitent +type Entity = { apiVersion: string; kind: string }; + +type EntityFilter = (entity: Entity, ctx: { apis: ApiHolder }) => boolean; +type AsyncEntityFilter = ( + entity: Entity, + context: { apis: ApiHolder }, +) => boolean | Promise; + +function allFilters( + ...ifs: (EntityFilter | undefined)[] +): EntityFilter | undefined { + const filtered = ifs.filter(Boolean) as EntityFilter[]; + if (!filtered.length) { + return undefined; + } + if (filtered.length === 1) { + return filtered[0]; + } + return (entity, ctx) => filtered.every(ifFunc => ifFunc(entity, ctx)); +} + +function anyFilters( + ...ifs: (EntityFilter | undefined)[] +): EntityFilter | undefined { + const filtered = ifs.filter(Boolean) as EntityFilter[]; + if (!filtered.length) { + return undefined; + } + if (filtered.length === 1) { + return filtered[0]; + } + return (entity, ctx) => filtered.some(ifFunc => ifFunc(entity, ctx)); +} + +function invertFilter(ifFunc?: EntityFilter): EntityFilter { + if (!ifFunc) { + return () => true; + } + return (entity, ctx) => !ifFunc(entity, ctx); +} + +export function convertLegacyEntityPage( + entityPageElement: React.JSX.Element, +): (FrontendModule | FrontendPlugin)[] { + let cardCounter = 1; + let routeCounter = 1; + + const collected: ExtensionDefinition[] = []; + + function traverse(element: React.ReactNode, parentFilter?: EntityFilter) { + if (!React.isValidElement(element)) { + return; + } + + const pageNode = maybeParseEntityPageNode(element); + if (pageNode) { + if (pageNode.type === 'route') { + const mergedIf = allFilters(parentFilter, pageNode.if); + + if (pageNode.path === '/') { + collected.push( + EntityCardBlueprint.makeWithOverrides({ + name: `discovered-entity-cards-${cardCounter++}`, + factory(originalFactory, { apis }) { + return originalFactory({ + type: 'full', + filter: mergedIf && (entity => mergedIf(entity, { apis })), + loader: () => Promise.resolve(pageNode.children), + }); + }, + }), + ); + } else { + collected.push( + EntityContentBlueprint.makeWithOverrides({ + name: `discovered-entity-route-${routeCounter++}`, + factory(originalFactory, { apis }) { + return originalFactory({ + defaultPath: pageNode.path, + defaultTitle: pageNode.title, + filter: mergedIf && (entity => mergedIf(entity, { apis })), + loader: () => Promise.resolve(pageNode.children), + }); + }, + }), + ); + } + } + if (pageNode.type === 'switch') { + if (pageNode.renderMultipleMatches === 'all') { + for (const entityCase of pageNode.cases) { + traverse( + entityCase.children, + allFilters(parentFilter, entityCase.if), + ); + } + } else { + let previousIf: EntityFilter = () => false; + for (const entityCase of pageNode.cases) { + const didNotMatchEarlier = invertFilter(previousIf); + traverse( + entityCase.children, + allFilters(parentFilter, entityCase.if, didNotMatchEarlier), + ); + previousIf = anyFilters(previousIf, entityCase.if)!; + } + } + } + return; + } + + React.Children.forEach( + (element.props as { children?: React.ReactNode })?.children, + child => { + traverse(child, parentFilter); + }, + ); + } + + traverse(entityPageElement); + + return [ + createFrontendPlugin({ + id: 'converted-orphan-entity-content', + extensions: collected, + }), + ]; +} + +type EntityRoute = { + type: 'route'; + path: string; + title: string; + if?: EntityFilter; + children: JSX.Element; +}; + +type EntitySwitchCase = { + if?: EntityFilter; + children: React.ReactNode; +}; + +type EntitySwitch = { + type: 'switch'; + cases: EntitySwitchCase[]; + renderMultipleMatches: 'first' | 'all'; +}; + +function wrapAsyncEntityFilter( + asyncFilter?: AsyncEntityFilter, +): EntityFilter | undefined { + if (!asyncFilter) { + return asyncFilter; + } + let loggedError = false; + return (entity, ctx) => { + const result = asyncFilter(entity, ctx); + if (result && typeof result === 'object' && 'then' in result) { + if (!loggedError) { + // eslint-disable-next-line no-console + console.error( + `convertLegacyEntityPage does not support async entity filters, skipping filter ${asyncFilter}`, + ); + loggedError = true; + } + return false; + } + return result; + }; +} + +function maybeParseEntityPageNode( + element: React.JSX.Element, +): EntityRoute | EntitySwitch | undefined { + if (getComponentData(element, ENTITY_ROUTE_KEY)) { + const props = element.props as EntityRoute; + return { + type: 'route', + path: props.path, + title: props.title, + if: props.if, + children: props.children, + }; + } + + const parentProps = element.props as { + children?: React.ReactNode; + renderMultipleMatches?: 'first' | 'all'; + }; + + const children = React.Children.toArray(parentProps?.children); + if (!children.length) { + return undefined; + } + + const cases = []; + for (const child of children) { + if (!getComponentData(child, ENTITY_SWITCH_KEY)) { + return undefined; + } + const props = (child as { props: EntitySwitchCase }).props; + + cases.push({ + if: wrapAsyncEntityFilter(props.if), + children: props.children, + }); + } + return { + type: 'switch', + cases, + renderMultipleMatches: parentProps?.renderMultipleMatches ?? 'first', + }; +} diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 6c2df56d92..5e64dfab21 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -19,6 +19,7 @@ export * from './apis'; export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyAppOptions } from './convertLegacyAppOptions'; +export { convertLegacyEntityPage } from './convertLegacyEntityPage'; export { convertLegacyPlugin } from './convertLegacyPlugin'; export { convertLegacyPageExtension } from './convertLegacyPageExtension'; export { diff --git a/yarn.lock b/yarn.lock index 0ea358c567..edbce6a52a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4231,6 +4231,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/plugin-catalog": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" From 2f93adeb98771a6de6e792c1323e968aa270748c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 8 Mar 2025 11:21:00 +0100 Subject: [PATCH 2/8] core-compat-api: move entity page conversion into convertLegacyApp Signed-off-by: Patrik Oldsberg --- ...tyPage.ts => collectEntityPageContents.ts} | 46 ++++++----- .../src/collectLegacyRoutes.tsx | 81 ++++++++++++++----- .../core-compat-api/src/convertLegacyApp.ts | 24 +++++- packages/core-compat-api/src/index.ts | 1 - 4 files changed, 110 insertions(+), 42 deletions(-) rename packages/core-compat-api/src/{convertLegacyEntityPage.ts => collectEntityPageContents.ts} (88%) diff --git a/packages/core-compat-api/src/convertLegacyEntityPage.ts b/packages/core-compat-api/src/collectEntityPageContents.ts similarity index 88% rename from packages/core-compat-api/src/convertLegacyEntityPage.ts rename to packages/core-compat-api/src/collectEntityPageContents.ts index 7df99240f1..c2a3ba2145 100644 --- a/packages/core-compat-api/src/convertLegacyEntityPage.ts +++ b/packages/core-compat-api/src/collectEntityPageContents.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { ApiHolder, getComponentData } from '@backstage/core-plugin-api'; import { - createFrontendPlugin, - ExtensionDefinition, - FrontendModule, - FrontendPlugin, -} from '@backstage/frontend-plugin-api'; + ApiHolder, + getComponentData, + BackstagePlugin as LegacyBackstagePlugin, +} from '@backstage/core-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import React from 'react'; import { EntityCardBlueprint, @@ -72,14 +71,18 @@ function invertFilter(ifFunc?: EntityFilter): EntityFilter { return (entity, ctx) => !ifFunc(entity, ctx); } -export function convertLegacyEntityPage( +export function collectEntityPageContents( entityPageElement: React.JSX.Element, -): (FrontendModule | FrontendPlugin)[] { + context: { + discoveryExtension( + extension: ExtensionDefinition, + plugin?: LegacyBackstagePlugin, + ): void; + }, +) { let cardCounter = 1; let routeCounter = 1; - const collected: ExtensionDefinition[] = []; - function traverse(element: React.ReactNode, parentFilter?: EntityFilter) { if (!React.isValidElement(element)) { return; @@ -91,9 +94,9 @@ export function convertLegacyEntityPage( const mergedIf = allFilters(parentFilter, pageNode.if); if (pageNode.path === '/') { - collected.push( + context.discoveryExtension( EntityCardBlueprint.makeWithOverrides({ - name: `discovered-entity-cards-${cardCounter++}`, + name: `discovered-${cardCounter++}`, factory(originalFactory, { apis }) { return originalFactory({ type: 'full', @@ -104,9 +107,11 @@ export function convertLegacyEntityPage( }), ); } else { - collected.push( + const name = `discovered-${routeCounter++}`; + + context.discoveryExtension( EntityContentBlueprint.makeWithOverrides({ - name: `discovered-entity-route-${routeCounter++}`, + name, factory(originalFactory, { apis }) { return originalFactory({ defaultPath: pageNode.path, @@ -116,6 +121,10 @@ export function convertLegacyEntityPage( }); }, }), + getComponentData( + pageNode.children, + 'core.plugin', + ), ); } } @@ -151,13 +160,6 @@ export function convertLegacyEntityPage( } traverse(entityPageElement); - - return [ - createFrontendPlugin({ - id: 'converted-orphan-entity-content', - extensions: collected, - }), - ]; } type EntityRoute = { @@ -192,7 +194,7 @@ function wrapAsyncEntityFilter( if (!loggedError) { // eslint-disable-next-line no-console console.error( - `convertLegacyEntityPage does not support async entity filters, skipping filter ${asyncFilter}`, + `collectEntityPageContents does not support async entity filters, skipping filter ${asyncFilter}`, ); loggedError = true; } diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index b0a55f9ddd..3cf16b96c0 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -30,6 +30,8 @@ import { createFrontendPlugin, ApiBlueprint, PageBlueprint, + FrontendModule, + createFrontendModule, } from '@backstage/frontend-plugin-api'; import React, { Children, ReactNode, isValidElement } from 'react'; import { Route, Routes } from 'react-router-dom'; @@ -38,6 +40,7 @@ import { convertLegacyRouteRefs, } from './convertLegacyRouteRef'; import { compatWrapper } from './compatWrapper'; +import { collectEntityPageContents } from './collectEntityPageContents'; /* @@ -102,7 +105,7 @@ function makeRoutingShimExtension(options: { }); } -function visitRouteChildren(options: { +export function visitRouteChildren(options: { children: ReactNode; parentExtensionId: string; context: { @@ -157,7 +160,10 @@ function visitRouteChildren(options: { /** @internal */ export function collectLegacyRoutes( flatRoutesElement: JSX.Element, -): FrontendPlugin[] { + entityPage?: JSX.Element, +): (FrontendPlugin | FrontendModule)[] { + const output = new Array(); + const pluginExtensions = new Map< LegacyBackstagePlugin, ExtensionDefinition[] @@ -262,20 +268,59 @@ export function collectLegacyRoutes( }, ); - return Array.from(pluginExtensions).map(([plugin, extensions]) => - createFrontendPlugin({ - id: plugin.getId(), - extensions: [ - ...extensions, - ...Array.from(plugin.getApis()).map(factory => - ApiBlueprint.make({ - name: factory.api.id, - params: { factory }, - }), - ), - ], - routes: convertLegacyRouteRefs(plugin.routes ?? {}), - externalRoutes: convertLegacyRouteRefs(plugin.externalRoutes ?? {}), - }), - ); + if (entityPage) { + collectEntityPageContents(entityPage, { + discoveryExtension(extension, plugin) { + if (!plugin || plugin.getId() === 'catalog') { + getPluginExtensions(orphanRoutesPlugin).push(extension); + } else { + getPluginExtensions(plugin).push(extension); + } + }, + }); + + const extensions = new Array(); + visitRouteChildren({ + children: entityPage, + parentExtensionId: `page:catalog/entity`, + context: { + pluginId: 'catalog', + extensions, + getUniqueName, + discoverPlugin(plugin) { + if (plugin.getId() !== 'catalog') { + getPluginExtensions(plugin); + } + }, + }, + }); + + output.push( + createFrontendModule({ + pluginId: 'catalog', + extensions, + }), + ); + } + + for (const [plugin, extensions] of pluginExtensions) { + output.push( + createFrontendPlugin({ + id: plugin.getId(), + extensions: [ + ...extensions, + ...Array.from(plugin.getApis()).map(factory => + ApiBlueprint.make({ + name: factory.api.id, + params: { factory }, + }), + ), + ], + routes: convertLegacyRouteRefs(plugin.routes ?? {}), + externalRoutes: convertLegacyRouteRefs(plugin.externalRoutes ?? {}), + }), + ); + } + + return output; } diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index 52efc96ee2..eb72cda204 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -59,9 +59,31 @@ function selectChildren( }); } +export interface ConvertLegacyAppOptions { + /** + * By providing an entity page element here it will be split up and converted + * into individual extensions for the catalog plugin in the new frontend + * system. + * + * In order to use this option the entity page and other catalog extensions + * must be removed from the legacy app, and the catalog plugin for the new + * frontend system must be installed instead. + * + * In order for this conversion to work the entity page must be a plain React + * element tree without any component wrapping anywhere between the root + * element and the `EntityLayout.Route` elements. + * + * When enabling this conversion you are likely to encounter duplicate entity + * page content provided both via the old structure and the new plugins. Any + * duplicate content needs to be removed from the old structure. + */ + entityPage?: React.JSX.Element; +} + /** @public */ export function convertLegacyApp( rootElement: React.JSX.Element, + options: ConvertLegacyAppOptions = {}, ): (FrontendPlugin | FrontendModule)[] { if (getComponentData(rootElement, 'core.type') === 'FlatRoutes') { return collectLegacyRoutes(rootElement); @@ -136,7 +158,7 @@ export function convertLegacyApp( disabled: true, }); - const collectedRoutes = collectLegacyRoutes(routesEl); + const collectedRoutes = collectLegacyRoutes(routesEl, options?.entityPage); return [ ...collectedRoutes, diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 5e64dfab21..6c2df56d92 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -19,7 +19,6 @@ export * from './apis'; export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyAppOptions } from './convertLegacyAppOptions'; -export { convertLegacyEntityPage } from './convertLegacyEntityPage'; export { convertLegacyPlugin } from './convertLegacyPlugin'; export { convertLegacyPageExtension } from './convertLegacyPageExtension'; export { From f861bfcfb787afd4815e17f576966e701f6361eb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 14:40:09 +0100 Subject: [PATCH 3/8] frontend-test-utils: add initial routes option and default config for renderInTestApp Signed-off-by: Patrik Oldsberg --- .changeset/gentle-lemons-explain.md | 5 +++++ .changeset/gentle-students-glow.md | 5 +++++ .../src/app/renderInTestApp.tsx | 21 +++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .changeset/gentle-lemons-explain.md create mode 100644 .changeset/gentle-students-glow.md diff --git a/.changeset/gentle-lemons-explain.md b/.changeset/gentle-lemons-explain.md new file mode 100644 index 0000000000..435ca4003b --- /dev/null +++ b/.changeset/gentle-lemons-explain.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added a `initialRouteEntries` option to `renderInTestApp`. diff --git a/.changeset/gentle-students-glow.md b/.changeset/gentle-students-glow.md new file mode 100644 index 0000000000..ade77cbb95 --- /dev/null +++ b/.changeset/gentle-students-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +The `renderInTestApp` helper now provides a default mock config with mock values for both `app.baseUrl` and `backend.baseUrl`. diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index cd9f4439b3..1643b06fac 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -36,6 +36,11 @@ import { } from '@backstage/frontend-plugin-api'; import appPlugin from '@backstage/plugin-app'; +const DEFAULT_MOCK_CONFIG = { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, +}; + /** * Options to customize the behavior of the test app. * @public @@ -73,6 +78,11 @@ export type TestAppOptions = { * Additional features to add to the test app. */ features?: FrontendFeature[]; + + /** + * Initial route entries to use for the router. + */ + initialRouteEntries?: string[]; }; const NavItem = (props: { @@ -150,7 +160,11 @@ export function renderInTestApp( }), RouterBlueprint.make({ params: { - Component: ({ children }) => {children}, + Component: ({ children }) => ( + + {children} + + ), }, }), ]; @@ -197,7 +211,10 @@ export function renderInTestApp( const app = createSpecializedApp({ features, config: ConfigReader.fromConfigs([ - { context: 'render-config', data: options?.config ?? {} }, + { + context: 'render-config', + data: options?.config ?? DEFAULT_MOCK_CONFIG, + }, ]), }); From c89cb147663dd39ac070654be1b9f9d7a60eed9e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 14:45:13 +0100 Subject: [PATCH 4/8] core-compat-api: tests and fixes for entity page conversion Signed-off-by: Patrik Oldsberg --- .../src/collectEntityPageContents.test.tsx | 171 ++++++++++++++++++ .../src/collectEntityPageContents.ts | 6 +- .../src/collectLegacyRoutes.test.tsx | 4 +- .../src/collectLegacyRoutes.tsx | 2 +- .../src/convertLegacyApp.test.tsx | 159 ++++++++++++++++ .../core-compat-api/src/convertLegacyApp.ts | 2 +- 6 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 packages/core-compat-api/src/collectEntityPageContents.test.tsx diff --git a/packages/core-compat-api/src/collectEntityPageContents.test.tsx b/packages/core-compat-api/src/collectEntityPageContents.test.tsx new file mode 100644 index 0000000000..53aa90f247 --- /dev/null +++ b/packages/core-compat-api/src/collectEntityPageContents.test.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2025 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 { ExtensionAttachToSpec } from '@backstage/frontend-plugin-api'; +import { EntityLayout, EntitySwitch, isKind } from '@backstage/plugin-catalog'; +import React from 'react'; +import { collectEntityPageContents } from './collectEntityPageContents'; +import { + createComponentExtension, + createPlugin, +} from '@backstage/core-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + resolveExtensionDefinition, + toInternalExtension, +} from '../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; + +const fooPlugin = createPlugin({ + id: 'foo', +}); + +const FooContent = fooPlugin.provide( + createComponentExtension({ + name: 'FooContent', + component: { sync: () =>
foo content
}, + }), +); +const OtherFooContent = fooPlugin.provide( + createComponentExtension({ + name: 'OtherFooContent', + component: { sync: () =>
other foo content
}, + }), +); + +const simpleTestContent = ( + + +
overview content
+
+ + + + +
bar content
+
+
+); + +const otherTestContent = ( + + +
other overview content
+
+ + + +
+); + +function collect(element: React.JSX.Element) { + const result = new Array<{ + id: string; + attachTo: ExtensionAttachToSpec; + }>(); + + collectEntityPageContents(element, { + discoverExtension(extension, plugin) { + const ext = toInternalExtension( + resolveExtensionDefinition(extension, { + namespace: plugin?.getId() ?? 'test', + }), + ); + result.push({ id: ext.id, attachTo: ext.attachTo }); + }, + }); + return result; +} + +describe('collectEntityPageContents', () => { + it('should collect contents from a simple entity page', () => { + expect(collect(simpleTestContent)).toMatchInlineSnapshot(` + [ + { + "attachTo": { + "id": "entity-content:catalog/overview", + "input": "cards", + }, + "id": "entity-card:test/discovered-1", + }, + { + "attachTo": { + "id": "page:catalog/entity", + "input": "contents", + }, + "id": "entity-content:foo/discovered-1", + }, + { + "attachTo": { + "id": "page:catalog/entity", + "input": "contents", + }, + "id": "entity-content:test/discovered-2", + }, + ] + `); + }); + + it('should collect contents from an entity page with an entity switch', () => { + expect( + collect( + + + {simpleTestContent} + + {otherTestContent} + , + ), + ).toMatchInlineSnapshot(` + [ + { + "attachTo": { + "id": "entity-content:catalog/overview", + "input": "cards", + }, + "id": "entity-card:test/discovered-1", + }, + { + "attachTo": { + "id": "page:catalog/entity", + "input": "contents", + }, + "id": "entity-content:foo/discovered-1", + }, + { + "attachTo": { + "id": "page:catalog/entity", + "input": "contents", + }, + "id": "entity-content:test/discovered-2", + }, + { + "attachTo": { + "id": "entity-content:catalog/overview", + "input": "cards", + }, + "id": "entity-card:test/discovered-2", + }, + { + "attachTo": { + "id": "page:catalog/entity", + "input": "contents", + }, + "id": "entity-content:foo/discovered-3", + }, + ] + `); + }); +}); diff --git a/packages/core-compat-api/src/collectEntityPageContents.ts b/packages/core-compat-api/src/collectEntityPageContents.ts index c2a3ba2145..7c26cd29ef 100644 --- a/packages/core-compat-api/src/collectEntityPageContents.ts +++ b/packages/core-compat-api/src/collectEntityPageContents.ts @@ -74,7 +74,7 @@ function invertFilter(ifFunc?: EntityFilter): EntityFilter { export function collectEntityPageContents( entityPageElement: React.JSX.Element, context: { - discoveryExtension( + discoverExtension( extension: ExtensionDefinition, plugin?: LegacyBackstagePlugin, ): void; @@ -94,7 +94,7 @@ export function collectEntityPageContents( const mergedIf = allFilters(parentFilter, pageNode.if); if (pageNode.path === '/') { - context.discoveryExtension( + context.discoverExtension( EntityCardBlueprint.makeWithOverrides({ name: `discovered-${cardCounter++}`, factory(originalFactory, { apis }) { @@ -109,7 +109,7 @@ export function collectEntityPageContents( } else { const name = `discovered-${routeCounter++}`; - context.discoveryExtension( + context.discoverExtension( EntityContentBlueprint.makeWithOverrides({ name, factory(originalFactory, { apis }) { diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index ce0b055c00..2d64f88535 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -57,7 +57,7 @@ describe('collectLegacyRoutes', () => { expect( collected.map(p => ({ - id: p.id, + id: p.$$type === '@backstage/FrontendPlugin' ? p.id : p.pluginId, extensions: OpaqueFrontendPlugin.toInternal(p).extensions.map(e => ({ id: e.id, attachTo: e.attachTo, @@ -177,7 +177,7 @@ describe('collectLegacyRoutes', () => { expect( collected.map(p => ({ - id: p.id, + id: p.$$type === '@backstage/FrontendPlugin' ? p.id : p.pluginId, extensions: OpaqueFrontendPlugin.toInternal(p).extensions.map(e => ({ id: e.id, attachTo: e.attachTo, diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index 3cf16b96c0..f6408b8b3f 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -270,7 +270,7 @@ export function collectLegacyRoutes( if (entityPage) { collectEntityPageContents(entityPage, { - discoveryExtension(extension, plugin) { + discoverExtension(extension, plugin) { if (!plugin || plugin.getId() === 'catalog') { getPluginExtensions(orphanRoutesPlugin).push(extension); } else { diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx index 7153fcb4be..c532c6da1a 100644 --- a/packages/core-compat-api/src/convertLegacyApp.test.tsx +++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx @@ -21,6 +21,16 @@ import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; import React, { ReactNode } from 'react'; import { Route } from 'react-router-dom'; import { convertLegacyApp } from './convertLegacyApp'; +import { + createApiFactory, + createComponentExtension, + createPlugin, +} from '@backstage/core-plugin-api'; +import { EntityLayout, EntitySwitch, isKind } from '@backstage/plugin-catalog'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const Root = ({ children }: { children: ReactNode }) => <>{children}; @@ -159,4 +169,153 @@ describe('convertLegacyApp', () => { }), ]); }); + + it('should convert entity pages', async () => { + const fooPlugin = createPlugin({ + id: 'foo', + }); + + const FooContent = fooPlugin.provide( + createComponentExtension({ + name: 'FooContent', + component: { sync: () =>
foo content
}, + }), + ); + const OtherFooContent = fooPlugin.provide( + createComponentExtension({ + name: 'OtherFooContent', + component: { sync: () =>
other foo content
}, + }), + ); + + const simpleTestContent = ( + + +
overview content
+
+ + + + +
bar content
+
+
+ ); + + const otherTestContent = ( + + +
other overview content
+
+ + + +
+ ); + + const entityPage = ( + + + {simpleTestContent} + + {otherTestContent} + + ); + + const converted = convertLegacyApp( + + test} /> + , + { entityPage }, + ); + + const catalogOverride = catalogPlugin.withOverrides({ + extensions: [ + catalogPlugin.getExtension('api:catalog').override({ + params: { + factory: createApiFactory( + catalogApiRef, + catalogApiMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'test', + metadata: { + name: 'x', + }, + spec: {}, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'other', + metadata: { + name: 'x', + }, + spec: {}, + }, + ], + }), + ), + }, + }), + ], + }); + + // Overview + const renderOverviewTest = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/test/x'], + }); + await expect( + renderOverviewTest.findByText('overview content'), + ).resolves.toBeInTheDocument(); + renderOverviewTest.unmount(); + + const renderOverviewOther = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/other/x'], + }); + await expect( + renderOverviewOther.findByText('other overview content'), + ).resolves.toBeInTheDocument(); + renderOverviewOther.unmount(); + + // Foo tab + const renderFooTest = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/test/x/foo'], + }); + await expect( + renderFooTest.findByText('foo content'), + ).resolves.toBeInTheDocument(); + renderFooTest.unmount(); + + const renderFooOther = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/other/x/foo'], + }); + await expect( + renderFooOther.findByText('other foo content'), + ).resolves.toBeInTheDocument(); + renderFooOther.unmount(); + + // Bar tab + const renderBarTest = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/test/x/bar'], + }); + await expect( + renderBarTest.findByText('bar content'), + ).resolves.toBeInTheDocument(); + renderBarTest.unmount(); + + const renderBarOther = await renderInTestApp(
, { + features: [catalogOverride, ...converted], + initialRouteEntries: ['/catalog/default/other/x/bar'], + }); + await expect( + renderBarOther.findByText('other overview content'), + ).resolves.toBeInTheDocument(); // /bar does not exist, fall back to rendering overview + renderBarOther.unmount(); + }); }); diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index eb72cda204..dac9849aac 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -86,7 +86,7 @@ export function convertLegacyApp( options: ConvertLegacyAppOptions = {}, ): (FrontendPlugin | FrontendModule)[] { if (getComponentData(rootElement, 'core.type') === 'FlatRoutes') { - return collectLegacyRoutes(rootElement); + return collectLegacyRoutes(rootElement, options?.entityPage); } const appRouterEls = selectChildren( From e21bed7ad39f6da894362e1f91a2d7a11d0588a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 15:26:19 +0100 Subject: [PATCH 5/8] docs/frontend-system: add docs for gradually migrating entity pages Signed-off-by: Patrik Oldsberg --- .../building-apps/08-migrating.md | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index 273c0cffbd..304bc0f84b 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -500,7 +500,84 @@ Continue this process for each of your legacy routes until you have migrated all ### Entity Pages -The entity pages are typically defined in `packages/app/src/components/catalog` and rendered as a child of the `/catalog/:namespace/:kind/:name` route. The entity pages are typically quite large and bringing in content from quite a lot of different plugins. At the moment we do not provide a way to gradually migrate entity pages to the new system, although that is planned as a future improvement. This means that the entire entity page and all of its plugins need to be migrated at once, including any other usages of those plugins. +The entity pages are typically defined in `packages/app/src/components/catalog` and rendered as a child of the `/catalog/:namespace/:kind/:name` route. The entity pages are typically quite large and bringing in content from quite a lot of different plugins. To help gradually migrate entity pages we provide the `entityPage` option in the `convertLegacyApp` helper. This option lets you pass in an entity page app element tree that will be converted to extensions that are added to the features returned from `convertLegacyApp`. + +To start the gradual migration of entity pages, add your `entityPages` to the `convertLegacyApp` call: + +```tsx title="in packages/app/src/App.tsx" +/* highlight-remove-next-line */ +const legacyFeatures = convertLegacyApp(routes); +/* highlight-add-next-line */ +const legacyFeatures = convertLegacyApp(routes, { entityPage }); +``` + +Next, you will need to fully migrate the catalog plugin itself. This is because only a single version of a plugin can be installed in the app at a time, so in order to start using the new version of the catalog plugin you need to remove all usage of the old one. This includes both the routes and entity pages. You will need to keep the structural helpers for the entity pages, such as `EntityLayout` and `EntitySwitch`, but remove any extensions like the `` and entity cards and content like `` and ``. + +Remove the following routes: + +```tsx title="in packages/app/src/App.tsx" +const routes = ( + + ... + {/* highlight-remove-start */} + } /> + } + > + {entityPage} + + {/* highlight-remove-end */} + ... + +); +``` + +And explicitly install the catalog plugin before the converted legacy features: + +```tsx title="in packages/app/src/App.tsx" +/* highlight-add-next-line */ +import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha'; + +const app = createApp({ + /* highlight-remove-next-line */ + features: [optionsModule, ...legacyFeatures], + /* highlight-add-next-line */ + features: [catalogPlugin, optionsModule, ...legacyFeatures], +}); +``` + +If you are not using the default `` you can install your custom catalog page as an override for now instead, and fully migrate it to the new system later. + +```tsx title="in packages/app/src/App.tsx" +/* highlight-remove-start */ +const catalogPluginOverride = catalogPlugin.withOverrides({ + extensions: [ + catalogPlugin.getExtension('page:catalog').override({ + params: { + loader: async () => ( + {/* ... */}} + /> + ), + }, + }), + ], +}); +/* highlight-remove-end */ + +const app = createApp({ + /* highlight-remove-next-line */ + features: [catalogPlugin, optionsModule, ...legacyFeatures], + /* highlight-add-next-line */ + features: [catalogPluginOverride, optionsModule, ...legacyFeatures], +}); +``` + +At this point you should be able to run the app and see that you're not using the new version of the catalog plugin. If you navigate to the entity pages you will likely see a lot of duplicate content at the bottom of the page. These are the duplicates of the entity cards provided by the catalog plugin itself that we mentioned earlier that you need to remove. Clean up the entity pages by removing cards and content from the catalog plugin such as `` and ``. + +Once the cleanup is complete you should be left with clean entity pages that are built using a mix of the old and new frontend system. From this point you can continue to gradually migrate plugins that provide content for the entity pages, until all plugins have been fully moved to the new system and the `entityPage` option can be removed. ### Sidebar From e7fab5548004e836e7235ff1b9282c5b33204b3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 16:39:51 +0100 Subject: [PATCH 6/8] changesets: added changeset for entityPage option for convertLegacyApp Signed-off-by: Patrik Oldsberg --- .changeset/six-taxis-film.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/six-taxis-film.md diff --git a/.changeset/six-taxis-film.md b/.changeset/six-taxis-film.md new file mode 100644 index 0000000000..38f2b33fe5 --- /dev/null +++ b/.changeset/six-taxis-film.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added the `entityPage` option to `convertLegacyApp`, which you can read more about in the [app migration docs](https://backstage.io/docs/frontend-system/building-apps/migrating#entity-pages). From 351178c39e7d8acd3dd644293609c63340a21c00 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 16:55:27 +0100 Subject: [PATCH 7/8] update API reports for convertLegacyApp entityPage option + fixes Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/report.api.md | 6 ++++++ packages/core-compat-api/src/convertLegacyApp.ts | 1 + packages/core-compat-api/src/index.ts | 5 ++++- packages/frontend-test-utils/report.api.md | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core-compat-api/report.api.md b/packages/core-compat-api/report.api.md index 9f7582db03..125533c6d3 100644 --- a/packages/core-compat-api/report.api.md +++ b/packages/core-compat-api/report.api.md @@ -33,8 +33,14 @@ export function compatWrapper(element: ReactNode): React_2.JSX.Element; // @public (undocumented) export function convertLegacyApp( rootElement: React_2.JSX.Element, + options?: ConvertLegacyAppOptions, ): (FrontendPlugin | FrontendModule)[]; +// @public (undocumented) +export interface ConvertLegacyAppOptions { + entityPage?: React_2.JSX.Element; +} + // @public (undocumented) export function convertLegacyAppOptions(options?: { apis?: Iterable; diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index dac9849aac..2a62a6c1c6 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -59,6 +59,7 @@ function selectChildren( }); } +/** @public */ export interface ConvertLegacyAppOptions { /** * By providing an entity page element here it will be split up and converted diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 6c2df56d92..b1168184f9 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -17,7 +17,10 @@ export * from './compatWrapper'; export * from './apis'; -export { convertLegacyApp } from './convertLegacyApp'; +export { + convertLegacyApp, + type ConvertLegacyAppOptions, +} from './convertLegacyApp'; export { convertLegacyAppOptions } from './convertLegacyAppOptions'; export { convertLegacyPlugin } from './convertLegacyPlugin'; export { convertLegacyPageExtension } from './convertLegacyPageExtension'; diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index e14c971419..b139538cbe 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -134,6 +134,7 @@ export type TestAppOptions = { config?: JsonObject; extensions?: ExtensionDefinition[]; features?: FrontendFeature[]; + initialRouteEntries?: string[]; }; export { withLogCollector }; From e236650d1f142f35140bef14cdd4bfbd251af4ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 11 Mar 2025 11:32:53 +0100 Subject: [PATCH 8/8] core-compat-api: update to use content entity card kind Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/src/collectEntityPageContents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-compat-api/src/collectEntityPageContents.ts b/packages/core-compat-api/src/collectEntityPageContents.ts index 7c26cd29ef..7cc97f0897 100644 --- a/packages/core-compat-api/src/collectEntityPageContents.ts +++ b/packages/core-compat-api/src/collectEntityPageContents.ts @@ -99,7 +99,7 @@ export function collectEntityPageContents( name: `discovered-${cardCounter++}`, factory(originalFactory, { apis }) { return originalFactory({ - type: 'full', + type: 'content', filter: mergedIf && (entity => mergedIf(entity, { apis })), loader: () => Promise.resolve(pageNode.children), });