From d34e0e5eee510e8ba57ede76dbc0e77724893c85 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 4 Mar 2025 09:43:57 +0100 Subject: [PATCH] core-compat-api: added convertLegacyAppOptions Signed-off-by: Patrik Oldsberg --- .changeset/six-shrimps-breathe.md | 46 +++++ .../building-apps/08-migrating.md | 14 +- packages/core-compat-api/report.api.md | 17 ++ .../src/convertLegacyAppOptions.test.tsx | 74 +++++++ .../src/convertLegacyAppOptions.tsx | 194 ++++++++++++++++++ packages/core-compat-api/src/index.ts | 1 + plugins/app/report.api.md | 30 +-- 7 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 .changeset/six-shrimps-breathe.md create mode 100644 packages/core-compat-api/src/convertLegacyAppOptions.test.tsx create mode 100644 packages/core-compat-api/src/convertLegacyAppOptions.tsx diff --git a/.changeset/six-shrimps-breathe.md b/.changeset/six-shrimps-breathe.md new file mode 100644 index 0000000000..3fd53222f5 --- /dev/null +++ b/.changeset/six-shrimps-breathe.md @@ -0,0 +1,46 @@ +--- +'@backstage/core-compat-api': patch +--- + +Added a new `convertLegacyAppOptions` helper that converts many of the options passed to `createApp` in the old frontend system to a module with app overrides for the new system. The supported options are `apis`, `icons`, `plugins`, `components`, and `themes`. + +For example, given the following options for the old `createApp`: + +```ts +import { createApp } from '@backstage/app-deafults'; + +const app = createApp({ + apis, + plugins, + icons: { + custom: MyIcon, + }, + components: { + SignInPage: MySignInPage, + }, + themes: [myTheme], +}); +``` + +They can be converted to the new system like this: + +```ts +import { createApp } from '@backstage/frontend-deafults'; +import { convertLegacyAppOptions } from '@backstage/core-compat-api'; + +const app = createApp({ + features: [ + convertLegacyAppOptions({ + apis, + plugins, + icons: { + custom: MyIcon, + }, + components: { + SignInPage: MySignInPage, + }, + themes: [myTheme], + }), + ], +}); +``` diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index 6efd0c7841..273c0cffbd 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -27,7 +27,7 @@ Let's start by addressing the change to `app.createRoot(...)`, which no longer a Given that the app element tree is most of what builds up the app, it's likely also going to be the majority of the migration effort. In order to make the migration as smooth as possible we have provided a helper that lets you convert an existing app element tree into plugins that you can install in a new app. This in turn allows for a gradual migration of individual plugins, rather than needing to migrate the entire app structure at once. -The helper is called `convertLegacyApp` and is exported from the `@backstage/core-compat-api` package, which you will need to add as a dependency to your app package: +The helper is called `convertLegacyApp` and is exported from the `@backstage/core-compat-api` package. We will also be using the `convertLegacyAppOptions` helper that lets us re-use the existing app options, also exported from the same package. You will need to add it as a dependency to your app package: ```bash yarn --cwd packages/app add @backstage/core-compat-api @@ -54,6 +54,11 @@ export default app.createRoot( Migrate it to the following: ```tsx title="in packages/app/src/App.tsx" +import { + convertLegacyApp, + convertLegacyAppOptions, +} from '@backstage/core-compat-api'; + const legacyFeatures = convertLegacyApp( <> @@ -64,9 +69,12 @@ const legacyFeatures = convertLegacyApp( , ); -const app = createApp({ +const optionsModule = convertLegacyAppOptions({ /* other options */ - features: [...legacyFeatures], +}); + +const app = createApp({ + features: [optionsModule, ...legacyFeatures], }); export default app.createRoot(); diff --git a/packages/core-compat-api/report.api.md b/packages/core-compat-api/report.api.md index ddb3bc6bf4..50aec1f646 100644 --- a/packages/core-compat-api/report.api.md +++ b/packages/core-compat-api/report.api.md @@ -7,15 +7,20 @@ import { AnalyticsApi } from '@backstage/core-plugin-api'; 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 { AnyApiFactory } from '@backstage/core-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; +import { AppComponents } from '@backstage/core-plugin-api'; +import { AppTheme } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionOverrides } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; +import { FeatureFlag } from '@backstage/core-plugin-api'; import { FrontendModule } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; @@ -31,6 +36,18 @@ export function convertLegacyApp( rootElement: React_2.JSX.Element, ): (FrontendPlugin | FrontendModule | ExtensionOverrides)[]; +// @public (undocumented) +export function convertLegacyAppOptions(options?: { + apis?: Iterable; + icons?: { + [key in string]: IconComponent; + }; + plugins?: Array; + components?: Partial; + themes?: AppTheme[]; + featureFlags?: (FeatureFlag & Omit)[]; +}): FrontendModule; + // @public (undocumented) export function convertLegacyPageExtension( LegacyExtension: ComponentType<{}>, diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.test.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.test.tsx new file mode 100644 index 0000000000..ddfaee0855 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyAppOptions.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { convertLegacyAppOptions } from './convertLegacyAppOptions'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + FrontendModule, + toInternalFrontendModule, +} from '../../frontend-plugin-api/src/wiring/createFrontendModule'; +import { + AppTheme, + createApiFactory, + createApiRef, + createPlugin, +} from '@backstage/core-plugin-api'; + +function serializeModule(module: FrontendModule) { + const { extensions } = toInternalFrontendModule(module); + return extensions.map(e => String(e)); +} + +const testApiRef = createApiRef({ id: 'test' }); +const test2ApiRef = createApiRef({ id: 'test2' }); + +describe('convertLegacyAppOptions', () => { + it('should ignore empty options', () => { + expect(serializeModule(convertLegacyAppOptions())).toMatchInlineSnapshot( + `[]`, + ); + }); + + it('should convert all options', () => { + expect( + serializeModule( + convertLegacyAppOptions({ + apis: [createApiFactory(testApiRef, 'foo')], + plugins: [ + createPlugin({ + id: 'test', + apis: [createApiFactory(test2ApiRef, 'bar')], + }), + ], + + icons: { test: () => null }, + components: { SignInPage: () => null }, + themes: [{ id: 'other-theme' } as AppTheme], + }), + ), + ).toMatchInlineSnapshot(` + [ + "Extension{id=api:app/test2}", + "Extension{id=api:app/test}", + "Extension{id=icon-bundle:app/app-options}", + "Extension{id=theme:app/light}", + "Extension{id=theme:app/dark}", + "Extension{id=theme:app/other-theme}", + "Extension{id=sign-in-page:app}", + ] + `); + }); +}); diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx new file mode 100644 index 0000000000..7a0a016161 --- /dev/null +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -0,0 +1,194 @@ +/* + * 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 React from 'react'; +import { + ApiBlueprint, + coreComponentRefs, + CoreErrorBoundaryFallbackProps, + createComponentExtension, + createExtension, + createFrontendModule, + ExtensionDefinition, + FrontendModule, + IconBundleBlueprint, + RouterBlueprint, + SignInPageBlueprint, + ThemeBlueprint, +} from '@backstage/frontend-plugin-api'; +import { + AnyApiFactory, + AppComponents, + AppTheme, + BackstagePlugin, + FeatureFlag, + IconComponent, +} from '@backstage/core-plugin-api'; +import { toLegacyPlugin } from './compatWrapper/BackwardsCompatProvider'; +import { compatWrapper } from './compatWrapper'; + +function componentCompatWrapper( + Component: React.ComponentType, +) { + return (props: TProps) => compatWrapper(); +} + +/** + * @public + */ +export function convertLegacyAppOptions( + options: { + apis?: Iterable; + + icons?: { [key in string]: IconComponent }; + + plugins?: Array; + + components?: Partial; + + themes?: AppTheme[]; + + featureFlags?: (FeatureFlag & Omit)[]; + } = {}, +): FrontendModule { + const { apis, icons, plugins, components, themes, featureFlags } = options; + + const allApis = [ + ...(plugins?.flatMap(plugin => [...plugin.getApis()]) ?? []), + ...(apis ?? []), + ]; + const deduplicatedApis = Array.from( + new Map(allApis.map(api => [api.api.id, api])).values(), + ); + const extensions: ExtensionDefinition[] = deduplicatedApis.map(factory => + ApiBlueprint.make({ name: factory.api.id, params: { factory } }), + ); + + if (icons) { + extensions.push( + IconBundleBlueprint.make({ + name: 'app-options', + params: { icons }, + }), + ); + } + + if (themes) { + // IF any themes are provided we need to disable the default ones, unless they are overridden + for (const id of ['light', 'dark']) { + if (!themes.some(theme => theme.id === id)) { + extensions.push( + createExtension({ + kind: 'theme', + name: id, + attachTo: { id: 'api:app/app-theme', input: 'themes' }, + disabled: true, + output: [], + factory: () => [], + }), + ); + } + } + extensions.push( + ...themes.map(theme => + ThemeBlueprint.make({ + name: theme.id, + params: { theme }, + }), + ), + ); + } + + if (components) { + const { + BootErrorPage, + ErrorBoundaryFallback, + NotFoundErrorPage, + Progress, + Router, + SignInPage, + ThemeProvider, + } = components; + + if (BootErrorPage) { + throw new Error( + 'components.BootErrorPage is not supported by convertLegacyAppOptions', + ); + } + if (ThemeProvider) { + throw new Error( + 'components.ThemeProvider is not supported by convertLegacyAppOptions', + ); + } + if (Router) { + extensions.push( + RouterBlueprint.make({ + params: { Component: componentCompatWrapper(Router) }, + }), + ); + } + if (SignInPage) { + extensions.push( + SignInPageBlueprint.make({ + params: { + loader: () => Promise.resolve(componentCompatWrapper(SignInPage)), + }, + }), + ); + } + if (Progress) { + extensions.push( + createComponentExtension({ + ref: coreComponentRefs.progress, + loader: { sync: () => componentCompatWrapper(Progress) }, + }), + ); + } + if (NotFoundErrorPage) { + extensions.push( + createComponentExtension({ + ref: coreComponentRefs.notFoundErrorPage, + loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) }, + }), + ); + } + if (ErrorBoundaryFallback) { + const WrappedErrorBoundaryFallback = ( + props: CoreErrorBoundaryFallbackProps, + ) => + compatWrapper( + , + ); + extensions.push( + createComponentExtension({ + ref: coreComponentRefs.errorBoundaryFallback, + loader: { + sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback), + }, + }), + ); + } + } + + return createFrontendModule({ + pluginId: 'app', + extensions, + featureFlags, + }); +} diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 7f99a7ab7a..6c2df56d92 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -18,6 +18,7 @@ export * from './compatWrapper'; export * from './apis'; export { convertLegacyApp } from './convertLegacyApp'; +export { convertLegacyAppOptions } from './convertLegacyAppOptions'; export { convertLegacyPlugin } from './convertLegacyPlugin'; export { convertLegacyPageExtension } from './convertLegacyPageExtension'; export { diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 89dde35f78..afd1afe500 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -29,6 +29,21 @@ const appPlugin: FrontendPlugin< {}, {}, { + 'sign-in-page:app': ExtensionDefinition<{ + kind: 'sign-in-page'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + ComponentType, + 'core.sign-in-page.component', + {} + >; + inputs: {}; + params: { + loader: () => Promise>; + }; + }>; app: ExtensionDefinition<{ config: {}; configInput: {}; @@ -383,21 +398,6 @@ const appPlugin: FrontendPlugin< factory: AnyApiFactory; }; }>; - 'sign-in-page:app': ExtensionDefinition<{ - kind: 'sign-in-page'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - ComponentType, - 'core.sign-in-page.component', - {} - >; - inputs: {}; - params: { - loader: () => Promise>; - }; - }>; 'app-root-element:app/oauth-request-dialog': ExtensionDefinition<{ kind: 'app-root-element'; name: 'oauth-request-dialog';