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/.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). 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 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/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/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 new file mode 100644 index 0000000000..7cc97f0897 --- /dev/null +++ b/packages/core-compat-api/src/collectEntityPageContents.ts @@ -0,0 +1,248 @@ +/* + * 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, + BackstagePlugin as LegacyBackstagePlugin, +} from '@backstage/core-plugin-api'; +import { ExtensionDefinition } 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 collectEntityPageContents( + entityPageElement: React.JSX.Element, + context: { + discoverExtension( + extension: ExtensionDefinition, + plugin?: LegacyBackstagePlugin, + ): void; + }, +) { + let cardCounter = 1; + let routeCounter = 1; + + 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 === '/') { + context.discoverExtension( + EntityCardBlueprint.makeWithOverrides({ + name: `discovered-${cardCounter++}`, + factory(originalFactory, { apis }) { + return originalFactory({ + type: 'content', + filter: mergedIf && (entity => mergedIf(entity, { apis })), + loader: () => Promise.resolve(pageNode.children), + }); + }, + }), + ); + } else { + const name = `discovered-${routeCounter++}`; + + context.discoverExtension( + EntityContentBlueprint.makeWithOverrides({ + name, + factory(originalFactory, { apis }) { + return originalFactory({ + defaultPath: pageNode.path, + defaultTitle: pageNode.title, + filter: mergedIf && (entity => mergedIf(entity, { apis })), + loader: () => Promise.resolve(pageNode.children), + }); + }, + }), + getComponentData( + pageNode.children, + 'core.plugin', + ), + ); + } + } + 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); +} + +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( + `collectEntityPageContents 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/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 b0a55f9ddd..f6408b8b3f 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, { + discoverExtension(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.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 52efc96ee2..2a62a6c1c6 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -59,12 +59,35 @@ function selectChildren( }); } +/** @public */ +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); + return collectLegacyRoutes(rootElement, options?.entityPage); } const appRouterEls = selectChildren( @@ -136,7 +159,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 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 }; 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, + }, ]), }); diff --git a/yarn.lock b/yarn.lock index 5bb6cbd76c..ee35e6c4b3 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:^"