core-compat-api: added convertLegacyAppOptions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-04 09:43:57 +01:00
parent e60e629a5e
commit d34e0e5eee
7 changed files with 358 additions and 18 deletions
+46
View File
@@ -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],
}),
],
});
```
@@ -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(
<>
<AlertDisplay transientTimeoutMs={2500} />
@@ -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();
+17
View File
@@ -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<AnyApiFactory>;
icons?: {
[key in string]: IconComponent;
};
plugins?: Array<BackstagePlugin>;
components?: Partial<AppComponents>;
themes?: AppTheme[];
featureFlags?: (FeatureFlag & Omit<FeatureFlag, 'pluginId'>)[];
}): FrontendModule;
// @public (undocumented)
export function convertLegacyPageExtension(
LegacyExtension: ComponentType<{}>,
@@ -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<string>({ id: 'test' });
const test2ApiRef = createApiRef<string>({ 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}",
]
`);
});
});
@@ -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<TProps extends {}>(
Component: React.ComponentType<TProps>,
) {
return (props: TProps) => compatWrapper(<Component {...props} />);
}
/**
* @public
*/
export function convertLegacyAppOptions(
options: {
apis?: Iterable<AnyApiFactory>;
icons?: { [key in string]: IconComponent };
plugins?: Array<BackstagePlugin>;
components?: Partial<AppComponents>;
themes?: AppTheme[];
featureFlags?: (FeatureFlag & Omit<FeatureFlag, 'pluginId'>)[];
} = {},
): 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(
<ErrorBoundaryFallback
{...props}
plugin={props.plugin && toLegacyPlugin(props.plugin)}
/>,
);
extensions.push(
createComponentExtension({
ref: coreComponentRefs.errorBoundaryFallback,
loader: {
sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback),
},
}),
);
}
}
return createFrontendModule({
pluginId: 'app',
extensions,
featureFlags,
});
}
+1
View File
@@ -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 {
+15 -15
View File
@@ -29,6 +29,21 @@ const appPlugin: FrontendPlugin<
{},
{},
{
'sign-in-page:app': ExtensionDefinition<{
kind: 'sign-in-page';
name: undefined;
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
ComponentType<SignInPageProps>,
'core.sign-in-page.component',
{}
>;
inputs: {};
params: {
loader: () => Promise<ComponentType<SignInPageProps>>;
};
}>;
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<SignInPageProps>,
'core.sign-in-page.component',
{}
>;
inputs: {};
params: {
loader: () => Promise<ComponentType<SignInPageProps>>;
};
}>;
'app-root-element:app/oauth-request-dialog': ExtensionDefinition<{
kind: 'app-root-element';
name: 'oauth-request-dialog';