Merge pull request #25914 from backstage/rugvip/forwards-compat

core-compat-api: add support for converting from the new system to the old
This commit is contained in:
Patrik Oldsberg
2024-08-06 12:10:22 +02:00
committed by GitHub
9 changed files with 541 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Both `compatWrapper` and `convertLegacyRouteRef` now support converting from the new system to the old.
+18
View File
@@ -44,6 +44,24 @@ export function convertLegacyRouteRef<
ref: ExternalRouteRef<TParams, TOptional>,
): ExternalRouteRef_2<TParams, TOptional>;
// @public
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: RouteRef_2<TParams>,
): RouteRef<TParams>;
// @public
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: SubRouteRef_2<TParams>,
): SubRouteRef<TParams>;
// @public
export function convertLegacyRouteRef<
TParams extends AnyRouteRefParams,
TOptional extends boolean,
>(
ref: ExternalRouteRef_2<TParams, TOptional>,
): ExternalRouteRef<TParams, TOptional>;
// @public
export function convertLegacyRouteRefs<
TRefs extends {
+1
View File
@@ -44,6 +44,7 @@
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@oriflame/backstage-plugin-score-card": "^0.8.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.0"
@@ -50,7 +50,9 @@ const legacyPluginStore = getOrCreateGlobalSingleton(
() => new WeakMap<NewBackstagePlugin, LegacyBackstagePlugin>(),
);
function toLegacyPlugin(plugin: NewBackstagePlugin): LegacyBackstagePlugin {
export function toLegacyPlugin(
plugin: NewBackstagePlugin,
): LegacyBackstagePlugin {
let legacy = legacyPluginStore.get(plugin);
if (legacy) {
return legacy;
@@ -14,10 +14,155 @@
* limitations under the License.
*/
import React from 'react';
import {
ApiHolder,
ApiRef,
AppContext,
useApp,
} from '@backstage/core-plugin-api';
import {
AnyRouteRefParams,
ComponentRef,
ComponentsApi,
CoreErrorBoundaryFallbackProps,
CoreNotFoundErrorPageProps,
CoreProgressProps,
ExternalRouteRef,
IconComponent,
IconsApi,
RouteFunc,
RouteRef,
RouteResolutionApi,
RouteResolutionApiResolveOptions,
SubRouteRef,
componentsApiRef,
coreComponentRefs,
iconsApiRef,
routeResolutionApiRef,
} from '@backstage/frontend-plugin-api';
import React, { ComponentType, useMemo } from 'react';
import { ReactNode } from 'react';
import { toLegacyPlugin } from './BackwardsCompatProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiProvider } from '../../../core-app-api/src/apis/system/ApiProvider';
import { useVersionedContext } from '@backstage/version-bridge';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef';
import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
class CompatComponentsApi implements ComponentsApi {
readonly #Progress: ComponentType<CoreProgressProps>;
readonly #NotFoundErrorPage: ComponentType<CoreNotFoundErrorPageProps>;
readonly #ErrorBoundaryFallback: ComponentType<CoreErrorBoundaryFallbackProps>;
constructor(app: AppContext) {
const components = app.getComponents();
const ErrorBoundaryFallback = (props: CoreErrorBoundaryFallbackProps) => (
<components.ErrorBoundaryFallback
{...props}
plugin={props.plugin && toLegacyPlugin(props.plugin)}
/>
);
this.#Progress = components.Progress;
this.#NotFoundErrorPage = components.NotFoundErrorPage;
this.#ErrorBoundaryFallback = ErrorBoundaryFallback;
}
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T> {
switch (ref.id) {
case coreComponentRefs.progress.id:
return this.#Progress as ComponentType<any>;
case coreComponentRefs.notFoundErrorPage.id:
return this.#NotFoundErrorPage as ComponentType<any>;
case coreComponentRefs.errorBoundaryFallback.id:
return this.#ErrorBoundaryFallback as ComponentType<any>;
default:
throw new Error(
`No backwards compatible component is available for ref '${ref.id}'`,
);
}
}
}
class CompatIconsApi implements IconsApi {
readonly #app: AppContext;
constructor(app: AppContext) {
this.#app = app;
}
getIcon(key: string): IconComponent | undefined {
return this.#app.getSystemIcon(key);
}
listIconKeys(): string[] {
return Object.keys(this.#app.getSystemIcons());
}
}
class CompatRouteResolutionApi implements RouteResolutionApi {
readonly #routeResolver: RouteResolver;
constructor(routeResolver: RouteResolver) {
this.#routeResolver = routeResolver;
}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams, any>,
options?: RouteResolutionApiResolveOptions | undefined,
): RouteFunc<TParams> | undefined {
const legacyRef = convertLegacyRouteRef(anyRouteRef as RouteRef<TParams>);
return this.#routeResolver.resolve(legacyRef, options?.sourcePath ?? '/');
}
}
class ForwardsCompatApis implements ApiHolder {
readonly #componentsApi: ComponentsApi;
readonly #iconsApi: IconsApi;
readonly #routeResolutionApi: RouteResolutionApi;
constructor(app: AppContext, routeResolver: RouteResolver) {
this.#componentsApi = new CompatComponentsApi(app);
this.#iconsApi = new CompatIconsApi(app);
this.#routeResolutionApi = new CompatRouteResolutionApi(routeResolver);
}
get<T>(ref: ApiRef<any>): T | undefined {
if (ref.id === componentsApiRef.id) {
return this.#componentsApi as T;
} else if (ref.id === iconsApiRef.id) {
return this.#iconsApi as T;
} else if (ref.id === routeResolutionApiRef.id) {
return this.#routeResolutionApi as T;
}
return undefined;
}
}
function NewAppApisProvider(props: { children: ReactNode }) {
const app = useApp();
const versionedRouteResolverContext = useVersionedContext<{
1: RouteResolver;
}>('routing-context');
if (!versionedRouteResolverContext) {
throw new Error('Routing context is not available');
}
const routeResolver = versionedRouteResolverContext.atVersion(1);
if (!routeResolver) {
throw new Error('RoutingContext v1 not available');
}
const appFallbackApis = useMemo(
() => new ForwardsCompatApis(app, routeResolver),
[app, routeResolver],
);
return <ApiProvider apis={appFallbackApis}>{props.children}</ApiProvider>;
}
export function ForwardsCompatProvider(props: { children: ReactNode }) {
// TODO(Rugvip): Implement
return <>{props.children}</>;
return <NewAppApisProvider>{props.children}</NewAppApisProvider>;
}
@@ -16,21 +16,28 @@
import React from 'react';
import {
componentsApiRef,
coreComponentRefs,
coreExtensionData,
createExtension,
iconsApiRef,
useRouteRef as useNewRouteRef,
createRouteRef as createNewRouteRef,
useApi,
} from '@backstage/frontend-plugin-api';
import {
createExtensionTester,
renderInTestApp,
renderInTestApp as renderInNewTestApp,
} from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import { compatWrapper } from './compatWrapper';
import {
createRouteRef,
useApp,
useRouteRef,
useRouteRef as useOldRouteRef,
createRouteRef as createOldRouteRef,
} from '@backstage/core-plugin-api';
import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
import { renderInTestApp as renderInOldTestApp } from '@backstage/test-utils';
describe('BackwardsCompatProvider', () => {
it('should convert the app context', () => {
@@ -74,14 +81,58 @@ describe('BackwardsCompatProvider', () => {
});
it('should convert the routing context', () => {
const routeRef = createRouteRef({ id: 'test' });
const routeRef = createOldRouteRef({ id: 'test' });
function Component() {
const link = useRouteRef(routeRef);
const link = useOldRouteRef(routeRef);
return <div>link: {link()}</div>;
}
renderInTestApp(compatWrapper(<Component />), {
renderInNewTestApp(compatWrapper(<Component />), {
mountedRoutes: { '/test': convertLegacyRouteRef(routeRef) },
});
expect(screen.getByText('link: /test')).toBeInTheDocument();
});
});
describe('ForwardsCompatProvider', () => {
it('should convert the app context', async () => {
function Component() {
const components = useApi(componentsApiRef);
const icons = useApi(iconsApiRef);
return (
<div data-testid="ctx">
components:{' '}
{Object.entries(coreComponentRefs)
.map(
([name, ref]) =>
`${name}=${Boolean(components.getComponent(ref))}`,
)
.join(', ')}
{'\n'}
icons: {icons.listIconKeys().join(', ')}
</div>
);
}
await renderInOldTestApp(compatWrapper(<Component />));
expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(`
"components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true
icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning"
`);
});
it('should convert the routing context', async () => {
const routeRef = createNewRouteRef();
function Component() {
const link = useNewRouteRef(routeRef);
return <div>link: {link()}</div>;
}
await renderInOldTestApp(compatWrapper(<Component />), {
mountedRoutes: { '/test': convertLegacyRouteRef(routeRef) },
});
@@ -0,0 +1,206 @@
/*
* 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 {
RouteRef as OldRouteRef,
SubRouteRef as OldSubRouteRef,
ExternalRouteRef as OldExternalRouteRef,
createRouteRef as createOldRouteRef,
createSubRouteRef as createOldSubRouteRef,
createExternalRouteRef as createOldExternalRouteRef,
} from '@backstage/core-plugin-api';
import {
RouteRef as NewRouteRef,
SubRouteRef as NewSubRouteRef,
ExternalRouteRef as NewExternalRouteRef,
createRouteRef as createNewRouteRef,
createSubRouteRef as createNewSubRouteRef,
createExternalRouteRef as createNewExternalRouteRef,
} from '@backstage/frontend-plugin-api';
import { convertLegacyRouteRef } from './convertLegacyRouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalRouteRef as toInternalNewRouteRef } from '../../frontend-plugin-api/src/routing/RouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalSubRouteRef as toInternalNewSubRouteRef } from '../../frontend-plugin-api/src/routing/SubRouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExternalRouteRef as toInternalNewExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef';
describe('convertLegacyRouteRef', () => {
it('converts old to new', () => {
const ref1 = createOldRouteRef({ id: 'ref1' });
const ref2 = createOldRouteRef({ id: 'ref2', params: ['p1', 'p2'] });
const ref1sub1 = createOldSubRouteRef({
id: 'sub1',
parent: ref1,
path: '/sub1',
});
const ref1sub2 = createOldSubRouteRef({
id: 'sub2',
parent: ref1,
path: '/sub2/:p3',
});
const ref2sub1 = createOldSubRouteRef({
id: 'sub1',
parent: ref2,
path: '/sub1/:p3',
});
const ref3 = createOldExternalRouteRef({
id: 'ref3',
});
const ref4 = createOldExternalRouteRef({
id: 'ref4',
optional: true,
defaultTarget: 'ref2',
params: ['p1', 'p2'],
});
const ref1Converted: NewRouteRef = convertLegacyRouteRef(ref1);
const ref2Converted: NewRouteRef = convertLegacyRouteRef(ref2);
const ref1sub1Converted: NewSubRouteRef = convertLegacyRouteRef(ref1sub1);
const ref1sub2Converted: NewSubRouteRef = convertLegacyRouteRef(ref1sub2);
const ref2sub1Converted: NewSubRouteRef = convertLegacyRouteRef(ref2sub1);
const ref3Converted: NewExternalRouteRef = convertLegacyRouteRef(ref3);
const ref4Converted: NewExternalRouteRef = convertLegacyRouteRef(ref4);
// Check for reference equality
expect(ref1).toBe(ref1Converted);
expect(ref2).toBe(ref2Converted);
expect(ref1sub1).toBe(ref1sub1Converted);
expect(ref1sub2).toBe(ref1sub2Converted);
expect(ref2sub1).toBe(ref2sub1Converted);
expect(ref3).toBe(ref3Converted);
expect(ref4).toBe(ref4Converted);
const ref1Internal = toInternalNewRouteRef(ref1Converted);
const ref2Internal = toInternalNewRouteRef(ref2Converted);
const ref1sub1Internal = toInternalNewSubRouteRef(ref1sub1Converted);
const ref1sub2Internal = toInternalNewSubRouteRef(ref1sub2Converted);
const ref2sub1Internal = toInternalNewSubRouteRef(ref2sub1Converted);
const ref3Internal = toInternalNewExternalRouteRef(ref3Converted);
const ref4Internal = toInternalNewExternalRouteRef(ref4Converted);
expect(ref1Internal.getDescription()).toBe(
'routeRef{type=absolute,id=ref1}',
);
expect(ref1Internal.getParams()).toEqual([]);
expect(ref2Internal.getDescription()).toBe(
'routeRef{type=absolute,id=ref2}',
);
expect(ref2Internal.getParams()).toEqual(['p1', 'p2']);
expect(ref1sub1Internal.getDescription()).toBe(
'routeRef{type=sub,id=sub1}',
);
expect(ref1sub1Internal.getParams()).toEqual([]);
expect(ref1sub1Internal.getParent()).toBe(ref1);
expect(ref1sub2Internal.getDescription()).toBe(
'routeRef{type=sub,id=sub2}',
);
expect(ref1sub2Internal.getParams()).toEqual(['p3']);
expect(ref1sub2Internal.getParent()).toBe(ref1);
expect(ref2sub1Internal.getDescription()).toBe(
'routeRef{type=sub,id=sub1}',
);
expect(ref2sub1Internal.getParams()).toEqual(['p1', 'p2', 'p3']);
expect(ref2sub1Internal.getParent()).toBe(ref2);
expect(ref3Internal.getDefaultTarget()).toBe(undefined);
expect(ref3Internal.getDescription()).toBe(
'routeRef{type=external,id=ref3}',
);
expect(ref3Internal.getParams()).toEqual([]);
expect(ref3Internal.optional).toBe(false);
expect(ref4Internal.getDefaultTarget()).toBe('ref2');
expect(ref4Internal.getDescription()).toBe(
'routeRef{type=external,id=ref4}',
);
expect(ref4Internal.getParams()).toEqual(['p1', 'p2']);
expect(ref4Internal.optional).toBe(true);
});
it('converts new to old', () => {
const ref1 = createNewRouteRef();
const ref2 = createNewRouteRef({ params: ['p1', 'p2'] });
const ref1sub1 = createNewSubRouteRef({
parent: ref1,
path: '/sub1',
});
const ref1sub2 = createNewSubRouteRef({
parent: ref1,
path: '/sub2/:p3',
});
const ref2sub1 = createNewSubRouteRef({
parent: ref2,
path: '/sub1/:p3',
});
const ref3 = createNewExternalRouteRef();
const ref4 = createNewExternalRouteRef({
optional: true,
defaultTarget: 'ref2',
params: ['p1', 'p2'],
});
const ref1Converted: OldRouteRef = convertLegacyRouteRef(ref1);
const ref2Converted: OldRouteRef = convertLegacyRouteRef(ref2);
const ref1sub1Converted: OldSubRouteRef = convertLegacyRouteRef(ref1sub1);
const ref1sub2Converted: OldSubRouteRef = convertLegacyRouteRef(ref1sub2);
const ref2sub1Converted: OldSubRouteRef = convertLegacyRouteRef(ref2sub1);
const ref3Converted: OldExternalRouteRef = convertLegacyRouteRef(ref3);
const ref4Converted: OldExternalRouteRef = convertLegacyRouteRef(ref4);
// Check for reference equality
expect(ref1).toBe(ref1Converted);
expect(ref2).toBe(ref2Converted);
expect(ref1sub1).toBe(ref1sub1Converted);
expect(ref1sub2).toBe(ref1sub2Converted);
expect(ref2sub1).toBe(ref2sub1Converted);
expect(ref3).toBe(ref3Converted);
expect(ref4).toBe(ref4Converted);
expect(String(ref1Converted)).toMatch(/^RouteRef\{created at '.*'\}$/);
expect(ref1Converted.params).toEqual([]);
expect(String(ref2Converted)).toMatch(/^RouteRef\{created at '.*'\}$/);
expect(ref2Converted.params).toEqual(['p1', 'p2']);
expect(String(ref1sub1Converted)).toMatch(
/^SubRouteRef\{at \/sub1 with parent created at '.*'\}$/,
);
expect(ref1sub1Converted.params).toEqual([]);
expect(ref1sub1Converted.parent).toBe(ref1);
expect(String(ref1sub2Converted)).toMatch(
/^SubRouteRef\{at \/sub2\/:p3 with parent created at '.*'\}$/,
);
expect(ref1sub2Converted.params).toEqual(['p3']);
expect(ref1sub2Converted.parent).toBe(ref1);
expect(String(ref2sub1Converted)).toMatch(
/^SubRouteRef\{at \/sub1\/:p3 with parent created at '.*'\}$/,
);
expect(ref2sub1Converted.params).toEqual(['p1', 'p2', 'p3']);
expect(ref2sub1Converted.parent).toBe(ref2);
expect(String(ref3Converted)).toMatch(
/^ExternalRouteRef\{created at '.*'\}$/,
);
expect(ref3Converted.params).toEqual([]);
expect(ref3Converted.optional).toBe(false);
expect(String(ref4Converted)).toMatch(
/^ExternalRouteRef\{created at '.*'\}$/,
);
expect(ref4Converted.params).toEqual(['p1', 'p2']);
expect(ref4Converted.optional).toBe(true);
});
});
@@ -116,16 +116,113 @@ export function convertLegacyRouteRef<
ref: LegacyExternalRouteRef<TParams, TOptional>,
): ExternalRouteRef<TParams, TOptional>;
/**
* A temporary helper to convert a new route ref to the legacy system.
*
* @public
* @remarks
*
* In the future the legacy createRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: RouteRef<TParams>,
): LegacyRouteRef<TParams>;
/**
* A temporary helper to convert a new sub route ref to the legacy system.
*
* @public
* @remarks
*
* In the future the legacy createSubRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: SubRouteRef<TParams>,
): LegacySubRouteRef<TParams>;
/**
* A temporary helper to convert a new external route ref to the legacy system.
*
* @public
* @remarks
*
* In the future the legacy createExternalRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<
TParams extends AnyRouteRefParams,
TOptional extends boolean,
>(
ref: ExternalRouteRef<TParams, TOptional>,
): LegacyExternalRouteRef<TParams, TOptional>;
export function convertLegacyRouteRef(
ref: LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef,
): RouteRef | SubRouteRef | ExternalRouteRef {
ref:
| LegacyRouteRef
| LegacySubRouteRef
| LegacyExternalRouteRef
| RouteRef
| SubRouteRef
| ExternalRouteRef,
):
| RouteRef
| SubRouteRef
| ExternalRouteRef
| LegacyRouteRef
| LegacySubRouteRef
| LegacyExternalRouteRef {
const isNew = '$$type' in ref;
const oldType = (ref as unknown as { [routeRefType]: unknown })[routeRefType];
// Ref has already been converted
if ('$$type' in ref) {
return ref as unknown as RouteRef | SubRouteRef | ExternalRouteRef;
if (isNew && oldType) {
return ref as any;
}
const type = (ref as unknown as { [routeRefType]: unknown })[routeRefType];
if (isNew) {
return convertNewToOld(
ref as unknown as RouteRef | SubRouteRef | ExternalRouteRef,
);
}
return convertOldToNew(ref, oldType);
}
function convertNewToOld(
ref: RouteRef | SubRouteRef | ExternalRouteRef,
): LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef {
if (ref.$$type === '@backstage/RouteRef') {
const newRef = toInternalRouteRef(ref);
return Object.assign(ref, {
[routeRefType]: 'absolute',
params: newRef.getParams(),
title: newRef.getDescription(),
} as Omit<LegacyRouteRef, '$$routeRefType'>) as unknown as LegacyRouteRef;
}
if (ref.$$type === '@backstage/SubRouteRef') {
const newRef = toInternalSubRouteRef(ref);
return Object.assign(ref, {
[routeRefType]: 'sub',
parent: convertLegacyRouteRef(newRef.getParent()),
params: newRef.getParams(),
} as Omit<LegacySubRouteRef, '$$routeRefType' | 'path'>) as unknown as LegacySubRouteRef;
}
if (ref.$$type === '@backstage/ExternalRouteRef') {
const newRef = toInternalExternalRouteRef(ref);
return Object.assign(ref, {
[routeRefType]: 'external',
params: newRef.getParams(),
defaultTarget: newRef.getDefaultTarget(),
} as Omit<LegacyExternalRouteRef, '$$routeRefType' | 'optional'>) as unknown as LegacyExternalRouteRef;
}
throw new Error(
`Failed to convert route ref, unknown type '${(ref as any).$$type}'`,
);
}
function convertOldToNew(
ref: LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef,
type: unknown,
): RouteRef | SubRouteRef | ExternalRouteRef {
if (type === 'absolute') {
const legacyRef = ref as LegacyRouteRef;
const legacyRefStr = String(legacyRef);
+1
View File
@@ -4171,6 +4171,7 @@ __metadata:
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@oriflame/backstage-plugin-score-card": ^0.8.0
"@testing-library/jest-dom": ^6.0.0