core-compat-api: tests and fixes for entity page conversion

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-10 14:45:13 +01:00
parent f861bfcfb7
commit c89cb14766
6 changed files with 337 additions and 7 deletions
@@ -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: () => <div>foo content</div> },
}),
);
const OtherFooContent = fooPlugin.provide(
createComponentExtension({
name: 'OtherFooContent',
component: { sync: () => <div>other foo content</div> },
}),
);
const simpleTestContent = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<div>overview content</div>
</EntityLayout.Route>
<EntityLayout.Route path="/foo" title="Foo">
<FooContent />
</EntityLayout.Route>
<EntityLayout.Route path="/bar" title="Bar">
<div>bar content</div>
</EntityLayout.Route>
</EntityLayout>
);
const otherTestContent = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<div>other overview content</div>
</EntityLayout.Route>
<EntityLayout.Route path="/foo" title="Foo">
<OtherFooContent />
</EntityLayout.Route>
</EntityLayout>
);
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(
<EntitySwitch>
<EntitySwitch.Case if={isKind('test')}>
{simpleTestContent}
</EntitySwitch.Case>
<EntitySwitch.Case>{otherTestContent}</EntitySwitch.Case>
</EntitySwitch>,
),
).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",
},
]
`);
});
});
@@ -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 }) {
@@ -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,
@@ -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 {
@@ -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: () => <div>foo content</div> },
}),
);
const OtherFooContent = fooPlugin.provide(
createComponentExtension({
name: 'OtherFooContent',
component: { sync: () => <div>other foo content</div> },
}),
);
const simpleTestContent = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<div>overview content</div>
</EntityLayout.Route>
<EntityLayout.Route path="/foo" title="Foo">
<FooContent />
</EntityLayout.Route>
<EntityLayout.Route path="/bar" title="Bar">
<div>bar content</div>
</EntityLayout.Route>
</EntityLayout>
);
const otherTestContent = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<div>other overview content</div>
</EntityLayout.Route>
<EntityLayout.Route path="/foo" title="Foo">
<OtherFooContent />
</EntityLayout.Route>
</EntityLayout>
);
const entityPage = (
<EntitySwitch>
<EntitySwitch.Case if={isKind('test')}>
{simpleTestContent}
</EntitySwitch.Case>
<EntitySwitch.Case>{otherTestContent}</EntitySwitch.Case>
</EntitySwitch>
);
const converted = convertLegacyApp(
<FlatRoutes>
<Route path="/test" element={<div>test</div>} />
</FlatRoutes>,
{ 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(<div />, {
features: [catalogOverride, ...converted],
initialRouteEntries: ['/catalog/default/test/x'],
});
await expect(
renderOverviewTest.findByText('overview content'),
).resolves.toBeInTheDocument();
renderOverviewTest.unmount();
const renderOverviewOther = await renderInTestApp(<div />, {
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(<div />, {
features: [catalogOverride, ...converted],
initialRouteEntries: ['/catalog/default/test/x/foo'],
});
await expect(
renderFooTest.findByText('foo content'),
).resolves.toBeInTheDocument();
renderFooTest.unmount();
const renderFooOther = await renderInTestApp(<div />, {
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(<div />, {
features: [catalogOverride, ...converted],
initialRouteEntries: ['/catalog/default/test/x/bar'],
});
await expect(
renderBarTest.findByText('bar content'),
).resolves.toBeInTheDocument();
renderBarTest.unmount();
const renderBarOther = await renderInTestApp(<div />, {
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();
});
});
@@ -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(