diff --git a/.changeset/silent-camels-walk.md b/.changeset/silent-camels-walk.md new file mode 100644 index 0000000000..244a615335 --- /dev/null +++ b/.changeset/silent-camels-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Added utilities for converting existing entity card and content extensions to the new frontend system. This is in particular useful when used in combination with the new `convertLegacyPlugin` utility from `@backstage/core-compat-api`. diff --git a/.changeset/thirty-balloons-end.md b/.changeset/thirty-balloons-end.md new file mode 100644 index 0000000000..f6e252988e --- /dev/null +++ b/.changeset/thirty-balloons-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added new utilities for converting legacy plugins and extensions to the new system. The `convertLegacyPlugin` option will convert an existing plugin to the new system, although you need to supply extensions for the plugin yourself. To help out with this, there is also a new `convertLegacyPageExtension` which converts an existing page extension to the new system. diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index c41f33e01b..170decee42 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -29,7 +29,12 @@ import { createExtensionOverrides, ApiBlueprint, } from '@backstage/frontend-plugin-api'; -import techdocsPlugin from '@backstage/plugin-techdocs/alpha'; +import { + techdocsPlugin, + TechDocsIndexPage, + TechDocsReaderPage, + EntityTechdocsContent, +} from '@backstage/plugin-techdocs'; import appVisualizerPlugin from '@backstage/plugin-app-visualizer'; import { homePage } from './HomePage'; import { convertLegacyApp } from '@backstage/core-compat-api'; @@ -45,6 +50,9 @@ import { } from '@backstage/integration-react'; import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; import { signInPageOverrides } from './overrides/SignInPage'; +import { convertLegacyPlugin } from '@backstage/core-compat-api'; +import { convertLegacyPageExtension } from '@backstage/core-compat-api'; +import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha'; /* @@ -75,6 +83,17 @@ TODO: /* app.tsx */ +const convertedTechdocsPlugin = convertLegacyPlugin(techdocsPlugin, { + extensions: [ + // TODO: We likely also need a way to convert an entire tree similar to collectLegacyRoutes + convertLegacyPageExtension(TechDocsIndexPage), + convertLegacyPageExtension(TechDocsReaderPage, { + defaultPath: '/docs/:namespace/:kind/:name/*', + }), + convertLegacyEntityContentExtension(EntityTechdocsContent), + ], +}); + const homePageExtension = createExtension({ name: 'myhomepage', attachTo: { id: 'page:home', input: 'props' }, @@ -114,7 +133,7 @@ const collectedLegacyPlugins = convertLegacyApp( const app = createApp({ features: [ pagesPlugin, - techdocsPlugin, + convertedTechdocsPlugin, userSettingsPlugin, homePlugin, appVisualizerPlugin, diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index 8d177b4dab..75b91cc055 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -8,6 +8,10 @@ import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/frontend-plugin-api'; +import { ComponentType } from 'react'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; @@ -26,6 +30,23 @@ export function convertLegacyApp( rootElement: React_2.JSX.Element, ): FrontendFeature[]; +// @public (undocumented) +export function convertLegacyPageExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + defaultPath?: string; + }, +): ExtensionDefinition; + +// @public (undocumented) +export function convertLegacyPlugin( + legacyPlugin: BackstagePlugin, + options: { + extensions: ExtensionDefinition[]; + }, +): BackstagePlugin_2; + // @public export function convertLegacyRouteRef( ref: RouteRef, diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 560a547af5..9ebc1a0185 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -34,7 +34,8 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/version-bridge": "workspace:^", - "@types/react": "^16.13.1 || ^17.0.0" + "@types/react": "^16.13.1 || ^17.0.0", + "lodash": "^4.17.21" }, "devDependencies": { "@backstage-community/plugin-puppetdb": "^0.1.18", diff --git a/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx b/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx new file mode 100644 index 0000000000..482474d580 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2024 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 { + createPlugin as createLegacyPlugin, + createRouteRef as createLegacyRouteRef, + createRoutableExtension, +} from '@backstage/core-plugin-api'; +import { coreExtensionData } from '@backstage/frontend-plugin-api'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { convertLegacyPageExtension } from './convertLegacyPageExtension'; +import { convertLegacyRouteRef } from './convertLegacyRouteRef'; + +const routeRef = createLegacyRouteRef({ id: 'test' }); +const legacyPlugin = createLegacyPlugin({ + id: 'test', + routes: { + test: routeRef, + }, +}); + +describe('convertLegacyPageExtension', () => { + it('should convert a page extension', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'ExamplePage', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyPageExtension(LegacyExtension); + expect(converted.kind).toBe('page'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('example'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(coreExtensionData.routePath)).toBe('/example'); + expect(tester.get(coreExtensionData.routeRef)).toBe(routeRef); + }); + + it('should convert a page extension with overrides', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'ExamplePage', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyPageExtension(LegacyExtension, { + name: 'other', + defaultPath: '/other', + }); + expect(converted.kind).toBe('page'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('other'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(coreExtensionData.routePath)).toBe('/other'); + expect(tester.get(coreExtensionData.routeRef)).toBe(routeRef); + }); +}); diff --git a/packages/core-compat-api/src/convertLegacyPageExtension.tsx b/packages/core-compat-api/src/convertLegacyPageExtension.tsx new file mode 100644 index 0000000000..7ef4e0faff --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyPageExtension.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2024 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 { + getComponentData, + RouteRef as LegacyRouteRef, +} from '@backstage/core-plugin-api'; +import { + ExtensionDefinition, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import kebabCase from 'lodash/kebabCase'; +import { convertLegacyRouteRef } from './convertLegacyRouteRef'; +import { ComponentType } from 'react'; +import React from 'react'; +import { compatWrapper } from './compatWrapper'; + +/** @public */ +export function convertLegacyPageExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + defaultPath?: string; + }, +): ExtensionDefinition { + const element = ; + + const extName = getComponentData(element, 'core.extensionName'); + if (!extName) { + throw new Error('Extension has no name'); + } + + const mountPoint = getComponentData( + element, + 'core.mountPoint', + ); + + const name = extName.endsWith('Page') + ? extName.slice(0, -'Page'.length) + : extName; + const kebabName = kebabCase(name); + + return PageBlueprint.make({ + name: overrides?.name ?? kebabName, + params: { + defaultPath: overrides?.defaultPath ?? `/${kebabName}`, + routeRef: mountPoint && convertLegacyRouteRef(mountPoint), + loader: async () => compatWrapper(element), + }, + }); +} diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx new file mode 100644 index 0000000000..657ee91725 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2024 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 { + createPlugin as createLegacyPlugin, + createRouteRef as createLegacyRouteRef, + createExternalRouteRef as createLegacyExternalRouteRef, + createApiFactory, + createApiRef, +} from '@backstage/core-plugin-api'; +import { convertLegacyPlugin } from './convertLegacyPlugin'; +import { PageBlueprint } from '@backstage/frontend-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalBackstagePlugin } from '../../frontend-plugin-api/src/wiring/createFrontendPlugin'; + +describe('convertLegacyPlugin', () => { + it('should convert a plain legacy plugin to a new plugin', () => { + expect( + convertLegacyPlugin(createLegacyPlugin({ id: 'test' }), { + extensions: [], + }), + ).toMatchInlineSnapshot(` + { + "$$type": "@backstage/BackstagePlugin", + "extensions": [], + "externalRoutes": {}, + "featureFlags": [], + "getExtension": [Function], + "id": "test", + "routes": {}, + "toString": [Function], + "version": "v1", + "withOverrides": [Function], + } + `); + }); + + it('should convert a legacy plugin with options to a new plugin', () => { + const apiRef = createApiRef({ id: 'plugin.test.client' }); + + const routeRef = createLegacyRouteRef({ id: 'test' }); + const extRouteRef = createLegacyExternalRouteRef({ id: 'testExt' }); + + const converted = convertLegacyPlugin( + createLegacyPlugin({ + id: 'test', + apis: [createApiFactory(apiRef, 'hello')], + routes: { test: routeRef }, + externalRoutes: { + testExt: extRouteRef, + }, + featureFlags: [{ name: 'test-flag' }], + }), + { + extensions: [ + PageBlueprint.make({ + params: { defaultPath: '/test', loader: async () => ({} as any) }, + }), + ], + }, + ); + + const internalConverted = toInternalBackstagePlugin(converted); + + expect(internalConverted.id).toBe('test'); + expect(internalConverted.routes).toEqual({ + test: routeRef, + }); + expect(internalConverted.externalRoutes).toEqual({ + testExt: extRouteRef, + }); + expect(internalConverted.featureFlags).toEqual([{ name: 'test-flag' }]); + expect(internalConverted.extensions.map(e => e.id)).toEqual([ + 'api:plugin.test.client', + 'page:test', + ]); + }); +}); diff --git a/packages/core-compat-api/src/convertLegacyPlugin.ts b/packages/core-compat-api/src/convertLegacyPlugin.ts new file mode 100644 index 0000000000..5856108f29 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyPlugin.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2024 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 { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api'; +import { + ApiBlueprint, + ExtensionDefinition, + BackstagePlugin as NewBackstagePlugin, + createFrontendPlugin, +} from '@backstage/frontend-plugin-api'; +import { convertLegacyRouteRefs } from './convertLegacyRouteRef'; + +/** @public */ +export function convertLegacyPlugin( + legacyPlugin: LegacyBackstagePlugin, + options: { extensions: ExtensionDefinition[] }, +): NewBackstagePlugin { + const apiExtensions = Array.from(legacyPlugin.getApis()).map(factory => + ApiBlueprint.make({ namespace: factory.api.id, params: { factory } }), + ); + return createFrontendPlugin({ + id: legacyPlugin.getId(), + featureFlags: [...legacyPlugin.getFeatureFlags()], + routes: convertLegacyRouteRefs(legacyPlugin.routes ?? {}), + externalRoutes: convertLegacyRouteRefs(legacyPlugin.externalRoutes ?? {}), + extensions: [...apiExtensions, ...options.extensions], + }); +} diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 88e1892eac..7f99a7ab7a 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -18,6 +18,8 @@ export * from './compatWrapper'; export * from './apis'; export { convertLegacyApp } from './convertLegacyApp'; +export { convertLegacyPlugin } from './convertLegacyPlugin'; +export { convertLegacyPageExtension } from './convertLegacyPageExtension'; export { convertLegacyRouteRef, convertLegacyRouteRefs, diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index f5828993e7..e5af309ba1 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -7,6 +7,7 @@ import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; @@ -95,6 +96,30 @@ export const catalogReactTranslationRef: TranslationRef< } >; +// @alpha (undocumented) +export function convertLegacyEntityCardExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + filter?: + | typeof EntityCardBlueprint.dataRefs.filterFunction.T + | typeof EntityCardBlueprint.dataRefs.filterExpression.T; + }, +): ExtensionDefinition; + +// @alpha (undocumented) +export function convertLegacyEntityContentExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + filter?: + | typeof EntityContentBlueprint.dataRefs.filterFunction.T + | typeof EntityContentBlueprint.dataRefs.filterExpression.T; + defaultPath?: string; + defaultTitle?: string; + }, +): ExtensionDefinition; + // @alpha @deprecated (undocumented) export function createEntityCardExtension< TConfig extends { diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index ef5d924200..f7b43a29b4 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -59,6 +59,7 @@ "dependencies": { "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", + "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.test.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.test.tsx new file mode 100644 index 0000000000..7dacd9c3e8 --- /dev/null +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.test.tsx @@ -0,0 +1,129 @@ +/* + * Copyright 2024 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 { + createPlugin as createLegacyPlugin, + createRouteRef as createLegacyRouteRef, + createRoutableExtension, +} from '@backstage/core-plugin-api'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { convertLegacyEntityCardExtension } from './convertLegacyEntityCardExtension'; +import { convertLegacyRouteRef } from '@backstage/core-compat-api'; +import { EntityContentBlueprint } from '../blueprints'; + +const routeRef = createLegacyRouteRef({ id: 'test' }); +const legacyPlugin = createLegacyPlugin({ + id: 'test', + routes: { + test: routeRef, + }, +}); + +describe('convertLegacyEntityCardExtension', () => { + it('should convert an entity card extension', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'EntityExampleCard', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyEntityCardExtension(LegacyExtension); + expect(converted.kind).toBe('entity-card'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('example'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(EntityContentBlueprint.dataRefs.filterExpression)).toBe( + undefined, + ); + expect(tester.get(EntityContentBlueprint.dataRefs.filterFunction)).toBe( + undefined, + ); + }); + + it('should convert an entity card extension with overrides', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'EntityExampleCard', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyEntityCardExtension(LegacyExtension, { + name: 'other', + filter: 'my-filter', + }); + expect(converted.kind).toBe('entity-card'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('other'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(EntityContentBlueprint.dataRefs.filterExpression)).toBe( + 'my-filter', + ); + expect(tester.get(EntityContentBlueprint.dataRefs.filterFunction)).toBe( + undefined, + ); + }); + + it('should support various naming patterns for entity card extensions', async () => { + const withName = (name: string) => { + const converted = convertLegacyEntityCardExtension( + legacyPlugin.provide( + createRoutableExtension({ + name, + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ), + ); + return converted.name; + }; + + expect(withName('EntityTestCard')).toBe(undefined); + expect(withName('EntityTestTrimCard')).toBe('trim'); + expect(withName('EntityTeStTrimCard')).toBe('trim'); + expect(withName('EntityExampleCard')).toBe('example'); + expect(withName('EntityExAmpleCard')).toBe('ex-ample'); + expect(withName('ExampleCard')).toBe('example-card'); + }); +}); diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx new file mode 100644 index 0000000000..76442f526d --- /dev/null +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2024 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 { compatWrapper } from '@backstage/core-compat-api'; +import { BackstagePlugin, getComponentData } from '@backstage/core-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import React, { ComponentType } from 'react'; +import { EntityCardBlueprint } from '../blueprints'; +import kebabCase from 'lodash/kebabCase'; + +/** @alpha */ +export function convertLegacyEntityCardExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + filter?: + | typeof EntityCardBlueprint.dataRefs.filterFunction.T + | typeof EntityCardBlueprint.dataRefs.filterExpression.T; + }, +): ExtensionDefinition { + const element = ; + + const extName = getComponentData(element, 'core.extensionName'); + if (!extName) { + throw new Error('Extension has no name'); + } + + const plugin = getComponentData(element, 'core.plugin'); + const pluginId = plugin?.getId(); + + const match = extName.match(/^Entity(.*)Card$/); + const infix = match?.[1] ?? extName; + + let name: string | undefined = infix; + if ( + pluginId && + name + .toLocaleLowerCase('en-US') + .startsWith(pluginId.toLocaleLowerCase('en-US')) + ) { + name = name.slice(pluginId.length); + if (!name) { + name = undefined; + } + } + name = name && kebabCase(name); + + return EntityCardBlueprint.make({ + name: overrides?.name ?? name, + params: { + filter: overrides?.filter, + loader: async () => compatWrapper(element), + }, + }); +} diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.test.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.test.tsx new file mode 100644 index 0000000000..6d7c8f0420 --- /dev/null +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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 { + createPlugin as createLegacyPlugin, + createRouteRef as createLegacyRouteRef, + createRoutableExtension, +} from '@backstage/core-plugin-api'; +import { coreExtensionData } from '@backstage/frontend-plugin-api'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { convertLegacyEntityContentExtension } from './convertLegacyEntityContentExtension'; +import { convertLegacyRouteRef } from '@backstage/core-compat-api'; +import { EntityContentBlueprint } from '../blueprints'; + +const routeRef = createLegacyRouteRef({ id: 'test' }); +const legacyPlugin = createLegacyPlugin({ + id: 'test', + routes: { + test: routeRef, + }, +}); + +describe('convertLegacyEntityContentExtension', () => { + it('should convert an entity content extension', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'EntityExampleContent', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyEntityContentExtension(LegacyExtension); + expect(converted.kind).toBe('entity-content'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('example'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(coreExtensionData.routePath)).toBe('/example'); + expect(tester.get(coreExtensionData.routeRef)).toBe(routeRef); + expect(tester.get(EntityContentBlueprint.dataRefs.filterExpression)).toBe( + undefined, + ); + expect(tester.get(EntityContentBlueprint.dataRefs.filterFunction)).toBe( + undefined, + ); + }); + + it('should convert an entity content extension with overrides', async () => { + const LegacyExtension = legacyPlugin.provide( + createRoutableExtension({ + name: 'EntityExampleContent', + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ); + + const converted = convertLegacyEntityContentExtension(LegacyExtension, { + name: 'other', + defaultPath: '/other', + defaultTitle: 'Other', + filter: 'my-filter', + }); + expect(converted.kind).toBe('entity-content'); + expect(converted.namespace).toBe(undefined); + expect(converted.name).toBe('other'); + + const tester = createExtensionTester(converted); + + await renderInTestApp(tester.reactElement(), { + mountedRoutes: { + '/': convertLegacyRouteRef(routeRef), + }, + }); + + await expect(screen.findByText('Hello')).resolves.toBeInTheDocument(); + + expect(tester.get(coreExtensionData.routePath)).toBe('/other'); + expect(tester.get(coreExtensionData.routeRef)).toBe(routeRef); + expect(tester.get(EntityContentBlueprint.dataRefs.filterExpression)).toBe( + 'my-filter', + ); + expect(tester.get(EntityContentBlueprint.dataRefs.filterFunction)).toBe( + undefined, + ); + }); + + it('should support various naming patterns for entity content extensions', async () => { + const withName = (name: string) => { + const converted = convertLegacyEntityContentExtension( + legacyPlugin.provide( + createRoutableExtension({ + name, + mountPoint: routeRef, + component: async () => () =>
Hello
, + }), + ), + ); + return converted.name; + }; + + expect(withName('EntityTestContent')).toBe(undefined); + expect(withName('EntityTestTrimContent')).toBe('trim'); + expect(withName('EntityTeStTrimContent')).toBe('trim'); + expect(withName('EntityExampleContent')).toBe('example'); + expect(withName('EntityExAmpleContent')).toBe('ex-ample'); + expect(withName('ExampleContent')).toBe('example-content'); + }); +}); diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx new file mode 100644 index 0000000000..e44b5671fc --- /dev/null +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2024 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 { + compatWrapper, + convertLegacyRouteRef, +} from '@backstage/core-compat-api'; +import { + BackstagePlugin, + getComponentData, + RouteRef as LegacyRouteRef, +} from '@backstage/core-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import kebabCase from 'lodash/kebabCase'; +import startCase from 'lodash/startCase'; +import React, { ComponentType } from 'react'; +import { EntityContentBlueprint } from '../blueprints'; + +/** @alpha */ +export function convertLegacyEntityContentExtension( + LegacyExtension: ComponentType<{}>, + overrides?: { + name?: string; + filter?: + | typeof EntityContentBlueprint.dataRefs.filterFunction.T + | typeof EntityContentBlueprint.dataRefs.filterExpression.T; + defaultPath?: string; + defaultTitle?: string; + }, +): ExtensionDefinition { + const element = ; + + const extName = getComponentData(element, 'core.extensionName'); + if (!extName) { + throw new Error('Extension has no name'); + } + + const mountPoint = getComponentData( + element, + 'core.mountPoint', + ); + + const plugin = getComponentData(element, 'core.plugin'); + const pluginId = plugin?.getId(); + + const match = extName.match(/^Entity(.*)Content$/); + const infix = match?.[1] ?? extName; + + let name: string | undefined = infix; + if ( + pluginId && + name + .toLocaleLowerCase('en-US') + .startsWith(pluginId.toLocaleLowerCase('en-US')) + ) { + name = name.slice(pluginId.length); + if (!name) { + name = undefined; + } + } + name = name && kebabCase(name); + + return EntityContentBlueprint.make({ + name: overrides?.name ?? name, + params: { + filter: overrides?.filter, + defaultPath: overrides?.defaultPath ?? `/${kebabCase(infix)}`, + defaultTitle: overrides?.defaultTitle ?? startCase(infix), + routeRef: mountPoint && convertLegacyRouteRef(mountPoint), + loader: async () => compatWrapper(element), + }, + }); +} diff --git a/plugins/catalog-react/src/alpha/converters/index.ts b/plugins/catalog-react/src/alpha/converters/index.ts new file mode 100644 index 0000000000..731ab846c0 --- /dev/null +++ b/plugins/catalog-react/src/alpha/converters/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2024 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. + */ + +export { convertLegacyEntityCardExtension } from './convertLegacyEntityCardExtension'; +export { convertLegacyEntityContentExtension } from './convertLegacyEntityContentExtension'; diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index 132b288008..3930fbde87 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -13,5 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export * from './blueprints'; export * from './extensions'; +export * from './converters'; diff --git a/yarn.lock b/yarn.lock index 301d7de22f..f20870e0c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4202,6 +4202,7 @@ __metadata: "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^15.0.0 "@types/react": ^16.13.1 || ^17.0.0 + lodash: ^4.17.21 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 @@ -5917,6 +5918,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^"