From 9a6db6bba2f8b3b0c34228cf01c4d911f09b5fc8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 17 Oct 2023 14:44:45 +0200 Subject: [PATCH 01/11] feat: create core interop api package Signed-off-by: Camila Belo Co-authored-by: Patrik Oldsberg --- packages/core-compat-api/.eslintrc.js | 1 + packages/core-compat-api/README.md | 12 ++++++++ packages/core-compat-api/package.json | 33 ++++++++++++++++++++++ packages/core-compat-api/src/index.ts | 16 +++++++++++ packages/core-compat-api/src/setupTests.ts | 16 +++++++++++ yarn.lock | 28 +++++++++++++++++- 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 packages/core-compat-api/.eslintrc.js create mode 100644 packages/core-compat-api/README.md create mode 100644 packages/core-compat-api/package.json create mode 100644 packages/core-compat-api/src/index.ts create mode 100644 packages/core-compat-api/src/setupTests.ts diff --git a/packages/core-compat-api/.eslintrc.js b/packages/core-compat-api/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/core-compat-api/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/core-compat-api/README.md b/packages/core-compat-api/README.md new file mode 100644 index 0000000000..5faa299f03 --- /dev/null +++ b/packages/core-compat-api/README.md @@ -0,0 +1,12 @@ +# @backstage/core-compat-api + +_This package was created through the Backstage CLI_. + +## Installation + +Install the package via Yarn: + +```sh +cd # if within a monorepo +yarn add @backstage/core-compat-api +``` diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json new file mode 100644 index 0000000000..76c3bc6b4e --- /dev/null +++ b/packages/core-compat-api/package.json @@ -0,0 +1,33 @@ +{ + "name": "@backstage/core-compat-api", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@testing-library/jest-dom": "^5.10.1" + }, + "files": [ + "dist" + ] +} diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/packages/core-compat-api/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/packages/core-compat-api/src/setupTests.ts b/packages/core-compat-api/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/packages/core-compat-api/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 0fe4bb7768..0d17afeec6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.3.1": +"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.1": version: 4.3.1 resolution: "@adobe/css-tools@npm:4.3.1" checksum: ad43456379ff391132aff687ece190cb23ea69395e23c9b96690eeabe2468da89a4aaf266e4f8b6eaab53db3d1064107ce0f63c3a974e864f4a04affc768da3f @@ -3952,6 +3952,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/core-compat-api@workspace:packages/core-compat-api": + version: 0.0.0-use.local + resolution: "@backstage/core-compat-api@workspace:packages/core-compat-api" + dependencies: + "@backstage/cli": "workspace:^" + "@testing-library/jest-dom": ^5.10.1 + languageName: unknown + linkType: soft + "@backstage/core-components@^0.13.5, @backstage/core-components@workspace:^, @backstage/core-components@workspace:packages/core-components": version: 0.0.0-use.local resolution: "@backstage/core-components@workspace:packages/core-components" @@ -16700,6 +16709,23 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^5.10.1": + version: 5.17.0 + resolution: "@testing-library/jest-dom@npm:5.17.0" + dependencies: + "@adobe/css-tools": ^4.0.1 + "@babel/runtime": ^7.9.2 + "@types/testing-library__jest-dom": ^5.9.1 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.5.6 + lodash: ^4.17.15 + redent: ^3.0.0 + checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.0.0": version: 6.1.4 resolution: "@testing-library/jest-dom@npm:6.1.4" From 8462548e8f1c39e44fb06f802127241a3fb6052d Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 17 Oct 2023 14:51:04 +0200 Subject: [PATCH 02/11] feat: move legacy collect routes Signed-off-by: Camila Belo Co-authored-by: Patrik Oldsberg --- packages/core-compat-api/package.json | 14 ++- .../src/collectLegacyRoutes.test.tsx | 65 +++++++++++ .../src/collectLegacyRoutes.tsx | 103 ++++++++++++++++++ yarn.lock | 9 ++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 packages/core-compat-api/src/collectLegacyRoutes.test.tsx create mode 100644 packages/core-compat-api/src/collectLegacyRoutes.tsx diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 76c3bc6b4e..19d3104103 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -25,9 +25,21 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/plugin-puppetdb": "workspace:^", + "@backstage/plugin-stackstorm": "workspace:^", + "@oriflame/backstage-plugin-score-card": "^0.7.0", "@testing-library/jest-dom": "^5.10.1" }, "files": [ "dist" - ] + ], + "peerDependencies": { + "react": "*", + "react-router-dom": "*" + }, + "dependencies": { + "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^" + } } diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx new file mode 100644 index 0000000000..530013c283 --- /dev/null +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { createPageExtension } from '@backstage/frontend-plugin-api'; +import { PuppetDbPage } from '@backstage/plugin-puppetdb'; +import { StackstormPage } from '@backstage/plugin-stackstorm'; +import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; +import { Route } from 'react-router-dom'; + +import { getComponentData } from '@backstage/core-plugin-api'; +import { collectLegacyRoutes } from './collectLegacyRoutes'; + +jest.mock('@backstage/frontend-plugin-api', () => ({ + ...jest.requireActual('@backstage/frontend-plugin-api'), + createPageExtension: opts => opts, +})); + +describe('collectLegacyRoutes', () => { + it('should collect legacy routes', () => { + const collected = collectLegacyRoutes( + + } /> + } /> + } /> + , + ); + + expect(collected).toEqual([ + createPageExtension({ + id: 'plugin.score-card.page', + defaultPath: 'score-board', + routeRef: getComponentData(, 'core.mountPoint'), + loader: expect.any(Function), + }), + createPageExtension({ + id: 'plugin.stackstorm.page', + defaultPath: 'stackstorm', + routeRef: getComponentData(, 'core.mountPoint'), + loader: expect.any(Function), + }), + createPageExtension({ + id: 'plugin.puppetDb.page', + defaultPath: 'puppetdb', + routeRef: getComponentData(, 'core.mountPoint'), + loader: expect.any(Function), + }), + // ?????????????? + ]); + }); +}); diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx new file mode 100644 index 0000000000..b40ae78439 --- /dev/null +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { Extension, createPageExtension } from '@backstage/frontend-plugin-api'; +import { Route, Routes } from 'react-router-dom'; +import { + BackstagePlugin, + RouteRef, + getComponentData, +} from '@backstage/core-plugin-api'; + +export function collectLegacyRoutes( + flatRoutesElement: JSX.Element, +): Extension[] { + // const results = traverseElementTree({ + // root, + // discoverers: [childDiscoverer, routeElementDiscoverer], + // collectors: { + // foo: createCollector( + // () => new Set(), + // (acc, node) => { + // const plugin = getComponentData(node, 'core.plugin'); + // if (plugin) { + // acc.add(plugin); + // } + // }, + // ) + // }, + // }) + const results = new Array>(); + + React.Children.forEach( + flatRoutesElement.props.children, + (route: ReactNode) => { + if (!React.isValidElement(route)) { + return; + } + + // TODO(freben): Handle feature flag and permissions framework wrapper elements + if (route.type !== Route) { + return; + } + + const routeElement = route.props.element; + + // TODO: to support deeper extension component, e.g. hidden within , use https://github.com/backstage/backstage/blob/518a34646b79ec2028cc0ed6bc67d4366c51c4d6/packages/core-app-api/src/routing/collectors.tsx#L69 + const plugin = getComponentData( + routeElement, + 'core.plugin', + ); + if (!plugin) { + return; + } + + const routeRef = getComponentData( + routeElement, + 'core.mountPoint', + ); + + const pluginId = plugin.getId(); + const path: string = route.props.path; + + const detectedExtension = createPageExtension({ + id: `plugin.${pluginId}.page`, + defaultPath: path[0] === '/' ? path.slice(1) : path, + routeRef, + + loader: async () => + route.props.children ? ( + + + + + + ) : ( + routeElement + ), + }); + + plugin.getApis(); // Create DI API extensions from these + + // TODO: Create converted plugin instance instead. We need to move over APIs etc. + results.push(detectedExtension); + }, + ); + + // TODO: For every legacy plugin that we find, make sure any matching plugin is disabled in the new system + return results; +} diff --git a/yarn.lock b/yarn.lock index 0d17afeec6..1c101630dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3957,7 +3957,16 @@ __metadata: resolution: "@backstage/core-compat-api@workspace:packages/core-compat-api" dependencies: "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-puppetdb": "workspace:^" + "@backstage/plugin-stackstorm": "workspace:^" + "@oriflame/backstage-plugin-score-card": ^0.7.0 "@testing-library/jest-dom": ^5.10.1 + peerDependencies: + react: "*" + react-router-dom: "*" languageName: unknown linkType: soft From 4830d0df028a64f2134e563fca2e1eae88fbf9d3 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 17 Oct 2023 15:18:44 +0200 Subject: [PATCH 03/11] feat: use collect legacy routes in app-next Co-authored-by: Patrik Oldsberg Signed-off-by: Patrik Oldsberg --- packages/app-next/package.json | 1 + packages/app-next/src/App.tsx | 37 ++++++++++++++++++- .../src/collectLegacyRoutes.tsx | 17 +++++++-- packages/core-compat-api/src/index.ts | 2 +- yarn.lock | 3 +- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 635a513c0f..71d888ee50 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -12,6 +12,7 @@ "@backstage/cli": "workspace:^", "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", + "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-app-api": "workspace:^", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index d64c4dbb15..5e9205e2c4 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -27,6 +27,7 @@ import homePlugin, { import { coreExtensionData, createExtension, + createApiExtension, createExtensionOverrides, createPageExtension, } from '@backstage/frontend-plugin-api'; @@ -34,6 +35,16 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react'; import techdocsPlugin from '@backstage/plugin-techdocs/alpha'; import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; import { homePage } from './HomePage'; +import { collectLegacyRoutes } from '@backstage/core-compat-api'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { Route } from 'react-router'; +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; +import { createApiFactory, configApiRef } from '@backstage/core-plugin-api'; +import { + ScmAuth, + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; /* @@ -83,6 +94,24 @@ const homePageExtension = createExtension({ }, }); +const scmAuthExtension = createApiExtension({ + factory: ScmAuth.createDefaultApiFactory(), +}); + +const scmIntegrationApi = createApiExtension({ + factory: createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +}); + +const collectedLegacyRoutes = collectLegacyRoutes( + + } /> + , +); + const app = createApp({ features: [ graphiqlPlugin, @@ -92,7 +121,13 @@ const app = createApp({ userSettingsPlugin, homePlugin, createExtensionOverrides({ - extensions: [entityPageExtension, homePageExtension], + extensions: [ + entityPageExtension, + homePageExtension, + scmAuthExtension, + scmIntegrationApi, + ...collectedLegacyRoutes, + ], }), ], /* Handled through config instead */ diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index b40ae78439..ea235d57a6 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -15,13 +15,18 @@ */ import React, { ReactNode } from 'react'; -import { Extension, createPageExtension } from '@backstage/frontend-plugin-api'; +import { + Extension, + createApiExtension, + createPageExtension, +} from '@backstage/frontend-plugin-api'; import { Route, Routes } from 'react-router-dom'; import { BackstagePlugin, RouteRef, getComponentData, } from '@backstage/core-plugin-api'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; export function collectLegacyRoutes( flatRoutesElement: JSX.Element, @@ -77,7 +82,7 @@ export function collectLegacyRoutes( const detectedExtension = createPageExtension({ id: `plugin.${pluginId}.page`, defaultPath: path[0] === '/' ? path.slice(1) : path, - routeRef, + routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined, loader: async () => route.props.children ? ( @@ -91,7 +96,13 @@ export function collectLegacyRoutes( ), }); - plugin.getApis(); // Create DI API extensions from these + results.push( + ...Array.from(plugin.getApis()).map(factory => + createApiExtension({ + factory, + }), + ), + ); // TODO: Create converted plugin instance instead. We need to move over APIs etc. results.push(detectedExtension); diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 4b9026cde5..2181b2fa9a 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export {}; +export { collectLegacyRoutes } from './collectLegacyRoutes'; diff --git a/yarn.lock b/yarn.lock index 1c101630dc..f06b7b486d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3952,7 +3952,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/core-compat-api@workspace:packages/core-compat-api": +"@backstage/core-compat-api@workspace:^, @backstage/core-compat-api@workspace:packages/core-compat-api": version: 0.0.0-use.local resolution: "@backstage/core-compat-api@workspace:packages/core-compat-api" dependencies: @@ -25274,6 +25274,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-app-api": "workspace:^" From c5e8be56c3bad51214fdbe8267b2232da29d928a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Oct 2023 15:44:31 +0200 Subject: [PATCH 04/11] core-compat-api: collect into legacy plugin instead of extension + test refactor Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- packages/app-next/src/App.tsx | 4 +- .../src/collectLegacyRoutes.test.tsx | 88 +++++++++++++------ .../src/collectLegacyRoutes.tsx | 69 +++++++-------- 3 files changed, 94 insertions(+), 67 deletions(-) diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 5e9205e2c4..194c26f193 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -106,7 +106,7 @@ const scmIntegrationApi = createApiExtension({ }), }); -const collectedLegacyRoutes = collectLegacyRoutes( +const collectedLegacyPlugin = collectLegacyRoutes( } /> , @@ -120,13 +120,13 @@ const app = createApp({ techdocsPlugin, userSettingsPlugin, homePlugin, + ...collectedLegacyPlugin, createExtensionOverrides({ extensions: [ entityPageExtension, homePageExtension, scmAuthExtension, scmIntegrationApi, - ...collectedLegacyRoutes, ], }), ], diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index 530013c283..486c373991 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -14,22 +14,15 @@ * limitations under the License. */ -import React from 'react'; import { FlatRoutes } from '@backstage/core-app-api'; -import { createPageExtension } from '@backstage/frontend-plugin-api'; import { PuppetDbPage } from '@backstage/plugin-puppetdb'; import { StackstormPage } from '@backstage/plugin-stackstorm'; import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; +import React from 'react'; import { Route } from 'react-router-dom'; -import { getComponentData } from '@backstage/core-plugin-api'; import { collectLegacyRoutes } from './collectLegacyRoutes'; -jest.mock('@backstage/frontend-plugin-api', () => ({ - ...jest.requireActual('@backstage/frontend-plugin-api'), - createPageExtension: opts => opts, -})); - describe('collectLegacyRoutes', () => { it('should collect legacy routes', () => { const collected = collectLegacyRoutes( @@ -40,26 +33,65 @@ describe('collectLegacyRoutes', () => { , ); - expect(collected).toEqual([ - createPageExtension({ - id: 'plugin.score-card.page', - defaultPath: 'score-board', - routeRef: getComponentData(, 'core.mountPoint'), - loader: expect.any(Function), - }), - createPageExtension({ - id: 'plugin.stackstorm.page', - defaultPath: 'stackstorm', - routeRef: getComponentData(, 'core.mountPoint'), - loader: expect.any(Function), - }), - createPageExtension({ - id: 'plugin.puppetDb.page', - defaultPath: 'puppetdb', - routeRef: getComponentData(, 'core.mountPoint'), - loader: expect.any(Function), - }), - // ?????????????? + expect( + collected.map(p => ({ + id: p.id, + extensions: p.extensions.map(e => ({ + id: e.id, + attachTo: e.attachTo, + disabled: e.disabled, + defaultConfig: e.configSchema?.parse({}), + })), + })), + ).toEqual([ + { + id: 'score-card', + extensions: [ + { + id: 'plugin.score-card.page', + attachTo: { id: 'core.routes', input: 'routes' }, + disabled: false, + defaultConfig: { path: 'score-board' }, + }, + { + id: 'apis.plugin.scoringdata.service', + attachTo: { id: 'core', input: 'apis' }, + disabled: false, + }, + ], + }, + { + id: 'stackstorm', + extensions: [ + { + id: 'plugin.stackstorm.page', + attachTo: { id: 'core.routes', input: 'routes' }, + disabled: false, + defaultConfig: { path: 'stackstorm' }, + }, + { + id: 'apis.plugin.stackstorm.service', + attachTo: { id: 'core', input: 'apis' }, + disabled: false, + }, + ], + }, + { + id: 'puppetDb', + extensions: [ + { + id: 'plugin.puppetDb.page', + attachTo: { id: 'core.routes', input: 'routes' }, + disabled: false, + defaultConfig: { path: 'puppetdb' }, + }, + { + id: 'apis.plugin.puppetdb.service', + attachTo: { id: 'core', input: 'apis' }, + disabled: false, + }, + ], + }, ]); }); }); diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index ea235d57a6..e030fecb5a 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -19,10 +19,12 @@ import { Extension, createApiExtension, createPageExtension, + createPlugin, + BackstagePlugin, } from '@backstage/frontend-plugin-api'; import { Route, Routes } from 'react-router-dom'; import { - BackstagePlugin, + BackstagePlugin as LegacyBackstagePlugin, RouteRef, getComponentData, } from '@backstage/core-plugin-api'; @@ -30,23 +32,8 @@ import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; export function collectLegacyRoutes( flatRoutesElement: JSX.Element, -): Extension[] { - // const results = traverseElementTree({ - // root, - // discoverers: [childDiscoverer, routeElementDiscoverer], - // collectors: { - // foo: createCollector( - // () => new Set(), - // (acc, node) => { - // const plugin = getComponentData(node, 'core.plugin'); - // if (plugin) { - // acc.add(plugin); - // } - // }, - // ) - // }, - // }) - const results = new Array>(); +): BackstagePlugin[] { + const results = new Array(); React.Children.forEach( flatRoutesElement.props.children, @@ -63,7 +50,7 @@ export function collectLegacyRoutes( const routeElement = route.props.element; // TODO: to support deeper extension component, e.g. hidden within , use https://github.com/backstage/backstage/blob/518a34646b79ec2028cc0ed6bc67d4366c51c4d6/packages/core-app-api/src/routing/collectors.tsx#L69 - const plugin = getComponentData( + const plugin = getComponentData( routeElement, 'core.plugin', ); @@ -79,24 +66,28 @@ export function collectLegacyRoutes( const pluginId = plugin.getId(); const path: string = route.props.path; - const detectedExtension = createPageExtension({ - id: `plugin.${pluginId}.page`, - defaultPath: path[0] === '/' ? path.slice(1) : path, - routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined, + const detectedExtensions = new Array>(); - loader: async () => - route.props.children ? ( - - - - - - ) : ( - routeElement - ), - }); + detectedExtensions.push( + createPageExtension({ + id: `plugin.${pluginId}.page`, + defaultPath: path[0] === '/' ? path.slice(1) : path, + routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined, - results.push( + loader: async () => + route.props.children ? ( + + + + + + ) : ( + routeElement + ), + }), + ); + + detectedExtensions.push( ...Array.from(plugin.getApis()).map(factory => createApiExtension({ factory, @@ -104,8 +95,12 @@ export function collectLegacyRoutes( ), ); - // TODO: Create converted plugin instance instead. We need to move over APIs etc. - results.push(detectedExtension); + results.push( + createPlugin({ + id: plugin.getId(), + extensions: detectedExtensions, + }), + ); }, ); From 685a4c8901bd2fc7867d06232b41b4732419f1ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Oct 2023 16:44:39 +0200 Subject: [PATCH 05/11] frontend-app-api: remove plugin duplicates Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .changeset/rude-tomatoes-itch.md | 5 +++ .../src/collectLegacyRoutes.tsx | 1 - .../frontend-app-api/src/wiring/createApp.tsx | 35 +++++++++++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 .changeset/rude-tomatoes-itch.md diff --git a/.changeset/rude-tomatoes-itch.md b/.changeset/rude-tomatoes-itch.md new file mode 100644 index 0000000000..1071f65756 --- /dev/null +++ b/.changeset/rude-tomatoes-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Installed features are now deduplicated both by reference and ID when available. Features passed to `createApp` now override both discovered and loaded features. diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index e030fecb5a..785e562624 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -104,6 +104,5 @@ export function collectLegacyRoutes( }, ); - // TODO: For every legacy plugin that we find, make sure any matching plugin is disabled in the new system return results; } diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 87b2042d34..8b411ac38b 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -267,6 +267,29 @@ export function createInstances(options: { return { coreInstance, instances }; } +function deduplicateFeatures( + allFeatures: (BackstagePlugin | ExtensionOverrides)[], +): (BackstagePlugin | ExtensionOverrides)[] { + // Start by removing duplicates by reference + const features = Array.from(new Set(allFeatures)); + + // Plugins are deduplicated by ID, last one wins + const seenIds = new Set(); + return features + .reverse() + .filter(feature => { + if (feature.$$type !== '@backstage/BackstagePlugin') { + return true; + } + if (seenIds.has(feature.id)) { + return false; + } + seenIds.add(feature.id); + return true; + }) + .reverse(); +} + /** @public */ export function createApp(options: { features?: (BackstagePlugin | ExtensionOverrides)[]; @@ -287,13 +310,11 @@ export function createApp(options: { const discoveredFeatures = getAvailableFeatures(config); const loadedFeatures = (await options.featureLoader?.({ config })) ?? []; - const allFeatures = Array.from( - new Set([ - ...discoveredFeatures, - ...(options.features ?? []), - ...loadedFeatures, - ]), - ); + const allFeatures = deduplicateFeatures([ + ...discoveredFeatures, + ...loadedFeatures, + ...(options.features ?? []), + ]); const { coreInstance } = createInstances({ features: allFeatures, From dca88645f6dd9a914a1659ca31a362d970859afb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Oct 2023 17:06:42 +0200 Subject: [PATCH 06/11] core-compat-api: add notes on way forward with legacy compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../src/collectLegacyRoutes.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index 785e562624..6de59fa214 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -30,6 +30,34 @@ import { } from '@backstage/core-plugin-api'; import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; +/* + +# Legacy interoperability + +Use-cases (prioritized): + 1. Slowly migrate over an existing app to DI, piece by piece + 2. Use a legacy plugin in a new DI app + 3. Use DI in an existing legacy app + +Starting point: use-case #1 + +Potential solutions: + 1. Codemods (we're not considering this for now) + 2. Legacy apps are migrated bottom-up, i.e. keep legacy root, replace pages with DI + 3. Legacy apps are migrated top-down i.e. switch out base to DI, legacy adapter allows for usage of existing app structure + +Chosen path: #3 + +Existing tasks: + - Adopters can migrate their existing app gradually (~4) + - Example-app uses legacy base with DI adapters + - Create an API that lets you inject DI into existing apps - working assumption is that this is enough + - Adopters can use legacy plugins in DI through adapters (~8) + - App-next uses DI base with legacy adapters + - Create a legacy adapter that is able to take an existing extension tree + +*/ + export function collectLegacyRoutes( flatRoutesElement: JSX.Element, ): BackstagePlugin[] { From ba0a665840f0257a0b6090ca72dcbbfb55f842aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Oct 2023 17:53:48 +0200 Subject: [PATCH 07/11] core-compat-api: add API report + fix Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/api-report.md | 16 ++++++++++++++++ .../core-compat-api/src/collectLegacyRoutes.tsx | 1 + 2 files changed, 17 insertions(+) create mode 100644 packages/core-compat-api/api-report.md diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md new file mode 100644 index 0000000000..5175c76ac8 --- /dev/null +++ b/packages/core-compat-api/api-report.md @@ -0,0 +1,16 @@ +## API Report File for "@backstage/core-compat-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/frontend-plugin-api'; + +// @public (undocumented) +export function collectLegacyRoutes( + flatRoutesElement: JSX.Element, +): BackstagePlugin[]; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index 6de59fa214..2b149e6285 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -58,6 +58,7 @@ Existing tasks: */ +/** @public */ export function collectLegacyRoutes( flatRoutesElement: JSX.Element, ): BackstagePlugin[] { From f2cb14651e09abd574c05832e033731fca9e9963 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 18 Oct 2023 09:26:15 +0200 Subject: [PATCH 08/11] test(frontend-app-api): cover features deduplication Signed-off-by: Camila Belo --- .../src/wiring/createApp.test.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 0f2a6d38ce..de574bc6a9 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -23,7 +23,7 @@ import { createThemeExtension, } from '@backstage/frontend-plugin-api'; import { createApp, createInstances } from './createApp'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import React from 'react'; @@ -244,4 +244,42 @@ describe('createApp', () => { } `); }); + + it('should deduplicate features keeping the last received one', async () => { + const duplicatedFeatureId = 'test'; + const app = createApp({ + configLoader: async () => new MockConfigApi({}), + features: [ + createPlugin({ + id: duplicatedFeatureId, + extensions: [ + createPageExtension({ + id: 'test.page.first', + defaultPath: '/', + loader: async () =>
First Page
, + }), + ], + }), + createPlugin({ + id: duplicatedFeatureId, + extensions: [ + createPageExtension({ + id: 'test.page.last', + defaultPath: '/', + loader: async () =>
Last Page
, + }), + ], + }), + ], + }); + + await renderWithEffects(app.createRoot()); + + await waitFor(() => + expect(screen.queryByText('First Page')).not.toBeInTheDocument(), + ); + await waitFor(() => + expect(screen.getByText('Last Page')).toBeInTheDocument(), + ); + }); }); From 0ea8e5d0351620a6b423c207d3e8d7a2841a73b2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Oct 2023 10:04:41 +0200 Subject: [PATCH 09/11] core-compat-api: add missing catalog info Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/catalog-info.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/core-compat-api/catalog-info.yaml diff --git a/packages/core-compat-api/catalog-info.yaml b/packages/core-compat-api/catalog-info.yaml new file mode 100644 index 0000000000..f20f0543a0 --- /dev/null +++ b/packages/core-compat-api/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-core-compat-api + title: '@backstage/core-compat-api' +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers From 514310a76ca9b17ece365febff0c34fd1773b81b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Oct 2023 12:18:31 +0200 Subject: [PATCH 10/11] app-next: naming tweak Signed-off-by: Patrik Oldsberg --- packages/app-next/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 194c26f193..4e42d9b268 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -106,7 +106,7 @@ const scmIntegrationApi = createApiExtension({ }), }); -const collectedLegacyPlugin = collectLegacyRoutes( +const collectedLegacyPlugins = collectLegacyRoutes( } /> , @@ -120,7 +120,7 @@ const app = createApp({ techdocsPlugin, userSettingsPlugin, homePlugin, - ...collectedLegacyPlugin, + ...collectedLegacyPlugins, createExtensionOverrides({ extensions: [ entityPageExtension, From 4e696ee7eb972f5821b105ea31aeadaa2e3034f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Oct 2023 12:20:44 +0200 Subject: [PATCH 11/11] core-compat-api: fix dependencies Signed-off-by: Patrik Oldsberg --- packages/core-compat-api/package.json | 6 +++--- yarn.lock | 25 ++++--------------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 19d3104103..d27049c2a4 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -29,14 +29,14 @@ "@backstage/plugin-puppetdb": "workspace:^", "@backstage/plugin-stackstorm": "workspace:^", "@oriflame/backstage-plugin-score-card": "^0.7.0", - "@testing-library/jest-dom": "^5.10.1" + "@testing-library/jest-dom": "^6.0.0" }, "files": [ "dist" ], "peerDependencies": { - "react": "*", - "react-router-dom": "*" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "dependencies": { "@backstage/core-plugin-api": "workspace:^", diff --git a/yarn.lock b/yarn.lock index f06b7b486d..636d159dd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.1": +"@adobe/css-tools@npm:^4.3.1": version: 4.3.1 resolution: "@adobe/css-tools@npm:4.3.1" checksum: ad43456379ff391132aff687ece190cb23ea69395e23c9b96690eeabe2468da89a4aaf266e4f8b6eaab53db3d1064107ce0f63c3a974e864f4a04affc768da3f @@ -3963,10 +3963,10 @@ __metadata: "@backstage/plugin-puppetdb": "workspace:^" "@backstage/plugin-stackstorm": "workspace:^" "@oriflame/backstage-plugin-score-card": ^0.7.0 - "@testing-library/jest-dom": ^5.10.1 + "@testing-library/jest-dom": ^6.0.0 peerDependencies: - react: "*" - react-router-dom: "*" + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft @@ -16718,23 +16718,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1": - version: 5.17.0 - resolution: "@testing-library/jest-dom@npm:5.17.0" - dependencies: - "@adobe/css-tools": ^4.0.1 - "@babel/runtime": ^7.9.2 - "@types/testing-library__jest-dom": ^5.9.1 - aria-query: ^5.0.0 - chalk: ^3.0.0 - css.escape: ^1.5.1 - dom-accessibility-api: ^0.5.6 - lodash: ^4.17.15 - redent: ^3.0.0 - checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:^6.0.0": version: 6.1.4 resolution: "@testing-library/jest-dom@npm:6.1.4"