chore: added backwards support for components api and new implementation

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-08-08 14:40:40 +02:00
parent 256dc5b396
commit 6fc64919b9
9 changed files with 82 additions and 5 deletions
@@ -0,0 +1,264 @@
/*
* 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 {
ApiBlueprint,
createExtensionInput,
createSwappableComponent,
SwappableComponentBlueprint,
swappableComponentsApiRef,
} from '@backstage/frontend-plugin-api';
import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
import { render, screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/frontend-test-utils';
const { ref: testRefA } = createSwappableComponent({ id: 'test.a' });
const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' });
const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' });
describe('DefaultSwappableComponentsApi', () => {
it('should provide components', async () => {
const api = DefaultSwappableComponentsApi.fromComponents([
{
ref: testRefA,
loader: () => () => <div>test.a</div>,
},
]);
const ComponentA = api.getComponent(testRefA);
render(<ComponentA />);
await expect(screen.findByText('test.a')).resolves.toBeInTheDocument();
});
it('should key extension refs by ID', async () => {
const mockLoader = jest.fn(() => <div>test.b</div>);
const api = DefaultSwappableComponentsApi.fromComponents([
{
ref: testRefB1,
loader: () => mockLoader,
},
]);
const ComponentB2 = api.getComponent(testRefB2);
render(<ComponentB2 />);
await expect(screen.findByText('test.b')).resolves.toBeInTheDocument();
});
describe('integration tests', () => {
const api = ApiBlueprint.makeWithOverrides({
name: 'swappable-components',
inputs: {
components: createExtensionInput([
SwappableComponentBlueprint.dataRefs.component,
]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
defineParams({
api: swappableComponentsApiRef,
deps: {},
factory: () =>
DefaultSwappableComponentsApi.fromComponents(
inputs.components.map(i =>
i.get(SwappableComponentBlueprint.dataRefs.component),
),
),
}),
);
},
});
describe('no api provided', () => {
it('should render the fallback if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
});
renderInTestApp(<MockComponent />, {});
await expect(
screen.findByTestId('test.mock'),
).resolves.toBeInTheDocument();
});
it('should render the default compnoent if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {});
await expect(
screen.findByText('test.mock'),
).resolves.toBeInTheDocument();
});
it('should render async loader component if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: async () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {});
await expect(
screen.findByText('test.mock'),
).resolves.toBeInTheDocument();
});
it('should transform props correctly', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => (props: { inner: string }) =>
<div>inner: {props.inner}</div>,
transformProps: (props: { external: string }) => ({
inner: props.external,
}),
});
renderInTestApp(<MockComponent external="test" />, {});
await expect(
screen.findByText('inner: test'),
).resolves.toBeInTheDocument();
});
});
describe('with overrides', () => {
it('should render the fallback if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
});
renderInTestApp(<MockComponent />, {
extensions: [api],
});
await expect(
screen.findByTestId('test.mock'),
).resolves.toBeInTheDocument();
});
it('should render the default compnoent if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {
extensions: [api],
});
await expect(
screen.findByText('test.mock'),
).resolves.toBeInTheDocument();
});
it('should render async loader component if no other component is provided', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: async () => () => <div>test.mock</div>,
});
renderInTestApp(<MockComponent />, {
extensions: [api],
});
await expect(
screen.findByText('test.mock'),
).resolves.toBeInTheDocument();
});
it('should render the component provided by the blueprint', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => () => <div>test.mock</div>,
});
const override = SwappableComponentBlueprint.make({
params: define =>
define({
component: MockComponent,
loader: () => () => <div>Overridden!</div>,
}),
});
renderInTestApp(<MockComponent />, {
extensions: [api, override],
});
await expect(
screen.findByText('Overridden!'),
).resolves.toBeInTheDocument();
});
it('should render the async component provided by the blueprint', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => () => <div>test.mock</div>,
});
const override = SwappableComponentBlueprint.make({
params: define =>
define({
component: MockComponent,
loader: async () => () => <div>Overridden!</div>,
}),
});
renderInTestApp(<MockComponent />, {
extensions: [api, override],
});
await expect(
screen.findByText('Overridden!'),
).resolves.toBeInTheDocument();
});
it('should transform props correctly', async () => {
const MockComponent = createSwappableComponent({
id: 'test.mock',
loader: () => (props: { inner: string }) =>
<div>inner: {props.inner}</div>,
transformProps: (props: { external: string }) => ({
inner: props.external,
}),
});
const override = SwappableComponentBlueprint.make({
params: define =>
define({
component: MockComponent,
loader: () => props => <div>overridden: {props.inner}</div>,
}),
});
renderInTestApp(<MockComponent external="test" />, {
extensions: [api, override],
});
await expect(
screen.findByText('overridden: test'),
).resolves.toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,74 @@
/*
* 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 {
SwappableComponentRef,
SwappableComponentsApi,
SwappableComponentBlueprint,
} from '@backstage/frontend-plugin-api';
import { OpaqueSwappableComponentRef } from '@internal/frontend';
import { lazy } from 'react';
/**
* Implementation for the {@link SwappableComponentsApi}
*
* @internal
*/
export class DefaultSwappableComponentsApi implements SwappableComponentsApi {
#components: Map<string, ((props: object) => JSX.Element | null) | undefined>;
static fromComponents(
components: Array<typeof SwappableComponentBlueprint.dataRefs.component.T>,
) {
return new DefaultSwappableComponentsApi(
new Map(
components.map(entry => {
return [
entry.ref.id,
entry.loader
? lazy(async () => ({
default: await entry.loader!(),
}))
: undefined,
];
}),
),
);
}
constructor(components: Map<string, any>) {
this.#components = components;
}
getComponent(
ref: SwappableComponentRef<any>,
): (props: object) => JSX.Element | null {
const OverrideComponent = this.#components.get(ref.id);
const { defaultComponent: DefaultComponent, transformProps } =
OpaqueSwappableComponentRef.toInternal(ref);
return (props: object) => {
const innerProps = transformProps?.(props) ?? props;
if (OverrideComponent) {
return <OverrideComponent {...innerProps} />;
}
return <DefaultComponent {...innerProps} />;
};
}
}
@@ -0,0 +1,17 @@
/*
* 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 { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
@@ -0,0 +1,58 @@
/*
* 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 {
ApiBlueprint,
createApiRef,
ErrorDisplay,
NotFoundErrorPage,
Progress,
} from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
/**
* This is the old component API that has been replaced by the new SwappableComponentsApi.
*
* This backwards compatibility implementation exists to avoid breaking older plugins.$
*
* This was added for the 1.42 release, and can be removed in the future.
*
* @internal
*/
export const LegacyComponentsApi = ApiBlueprint.make({
name: 'components',
params: defineParams =>
defineParams({
api: createApiRef<{
getComponent(ref: { id: string }): ComponentType<any>;
}>({ id: 'core.components' }),
deps: {},
factory: () => ({
getComponent(ref) {
if (ref.id === 'core.components.progress') {
return Progress;
}
if (ref.id === 'core.components.notFoundErrorPage') {
return NotFoundErrorPage;
}
if (ref.id === 'core.components.errorBoundaryFallback') {
return ErrorDisplay;
}
throw new Error(`No implementation found for component ref ${ref}`);
},
}),
}),
});
@@ -20,8 +20,7 @@ import {
ApiBlueprint,
swappableComponentsApiRef,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { DefaultSwappableComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi';
import { DefaultSwappableComponentsApi } from '../apis/SwappableComponentsApi';
/**
* Contains the shareable components installed into the app.
+1
View File
@@ -21,6 +21,7 @@ export { AppRoot } from './AppRoot';
export { AppRoutes } from './AppRoutes';
export { AppThemeApi, DarkTheme, LightTheme } from './AppThemeApi';
export { SwappableComponentsApi } from './SwappableComponentsApi';
export { LegacyComponentsApi } from './LegacyComponentsApi';
export { IconsApi } from './IconsApi';
export { FeatureFlagsApi } from './FeatureFlagsApi';
export { TranslationsApi } from './TranslationsApi';
+2
View File
@@ -36,6 +36,7 @@ import {
Progress,
NotFoundErrorPage,
ErrorBoundary,
LegacyComponentsApi,
} from './extensions';
import { apis } from './defaultApis';
@@ -65,5 +66,6 @@ export const appPlugin = createFrontendPlugin({
Progress,
NotFoundErrorPage,
ErrorBoundary,
LegacyComponentsApi,
],
});