frontend-app-api: key components by id instead of ref + tests

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-01-26 13:14:38 +01:00
parent 1e61ad374a
commit fb9b5e7bec
6 changed files with 153 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
The default `ComponentsApi` implementation now uses the `ComponentRef` ID as the component key, rather than the reference instance. This fixes a bug where duplicate installations of `@backstage/frontend-plugin-api` would break the app.
@@ -0,0 +1,111 @@
/*
* 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 {
coreExtensionData,
createComponentExtension,
createComponentRef,
createExtension,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { resolveAppNodeSpecs } from '../../../tree/resolveAppNodeSpecs';
import { resolveAppTree } from '../../../tree/resolveAppTree';
import { App } from '../../../extensions/App';
import { DefaultComponentsApi } from './DefaultComponentsApi';
import { render, screen } from '@testing-library/react';
import { instantiateAppNodeTree } from '../../../tree/instantiateAppNodeTree';
const testRefA = createComponentRef({ id: 'test.a' });
const testRefB1 = createComponentRef({ id: 'test.b' });
const testRefB2 = createComponentRef({ id: 'test.b' });
const baseOverrides = createExtensionOverrides({
extensions: [
App,
createExtension({
namespace: 'app',
name: 'root',
attachTo: { id: 'app', input: 'root' },
output: {
element: coreExtensionData.reactElement,
},
factory() {
return {
element: <div>root</div>,
};
},
}),
],
});
describe('DefaultComponentsApi', () => {
it('should provide components', () => {
const tree = resolveAppTree(
'app',
resolveAppNodeSpecs({
features: [
baseOverrides,
createExtensionOverrides({
extensions: [
createComponentExtension({
ref: testRefA,
loader: { sync: () => () => <div>test.a</div> },
}),
],
}),
],
}),
);
instantiateAppNodeTree(tree.root);
const api = DefaultComponentsApi.fromTree(tree);
const ComponentA = api.getComponent(testRefA);
render(<ComponentA />);
expect(screen.getByText('test.a')).toBeInTheDocument();
});
it('should key extension refs by ID', () => {
const tree = resolveAppTree(
'app',
resolveAppNodeSpecs({
features: [
baseOverrides,
createExtensionOverrides({
extensions: [
createComponentExtension({
ref: testRefB1,
loader: { sync: () => () => <div>test.b</div> },
}),
],
}),
],
}),
);
instantiateAppNodeTree(tree.root);
const api = DefaultComponentsApi.fromTree(tree);
const ComponentB1 = api.getComponent(testRefB1);
const ComponentB2 = api.getComponent(testRefB2);
expect(ComponentB1).toBe(ComponentB2);
render(<ComponentB2 />);
expect(screen.getByText('test.b')).toBeInTheDocument();
});
});
@@ -15,7 +15,12 @@
*/
import { ComponentType } from 'react';
import { ComponentRef, ComponentsApi } from '@backstage/frontend-plugin-api';
import {
AppTree,
ComponentRef,
ComponentsApi,
createComponentExtension,
} from '@backstage/frontend-plugin-api';
/**
* Implementation for the {@linkComponentApi}
@@ -23,14 +28,29 @@ import { ComponentRef, ComponentsApi } from '@backstage/frontend-plugin-api';
* @internal
*/
export class DefaultComponentsApi implements ComponentsApi {
#components: Map<ComponentRef<any>, ComponentType<any>>;
#components: Map<string, ComponentType<any>>;
constructor(components: Map<ComponentRef<any>, any>) {
static fromTree(tree: AppTree) {
const componentEntries = tree.root.edges.attachments
.get('components')
?.reduce((map, e) => {
const data = e.instance?.getData(
createComponentExtension.componentDataRef,
);
if (data) {
map.set(data.ref.id, data.impl);
}
return map;
}, new Map<string, ComponentType>());
return new DefaultComponentsApi(componentEntries ?? new Map());
}
constructor(components: Map<string, any>) {
this.#components = components;
}
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T> {
const impl = this.#components.get(ref);
const impl = this.#components.get(ref.id);
if (!impl) {
throw new Error(`No implementation found for component ref ${ref}`);
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { DefaultComponentsApi } from './ComponentsApi';
export { DefaultComponentsApi } from './DefaultComponentsApi';
@@ -31,17 +31,22 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res
/** @internal */
export function resolveAppNodeSpecs(options: {
features: FrontendFeature[];
builtinExtensions: Extension<unknown>[];
parameters: Array<ExtensionParameters>;
features?: FrontendFeature[];
builtinExtensions?: Extension<unknown>[];
parameters?: Array<ExtensionParameters>;
forbidden?: Set<string>;
}): AppNodeSpec[] {
const { builtinExtensions, parameters, forbidden = new Set() } = options;
const {
builtinExtensions = [],
parameters = [],
forbidden = new Set(),
features = [],
} = options;
const plugins = options.features.filter(
const plugins = features.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
);
const overrides = options.features.filter(
const overrides = features.filter(
(f): f is ExtensionOverrides =>
f.$$type === '@backstage/ExtensionOverrides',
);
@@ -19,11 +19,9 @@ import { ConfigReader } from '@backstage/config';
import {
AppTree,
appTreeApiRef,
ComponentRef,
componentsApiRef,
coreExtensionData,
createApiExtension,
createComponentExtension,
createThemeExtension,
createTranslationExtension,
FrontendFeature,
@@ -373,22 +371,10 @@ function createApiHolder(
factory: () => routeResolutionApi,
});
const componentsExtensions =
tree.root.edges.attachments
.get('components')
?.map(e => e.instance?.getData(createComponentExtension.componentDataRef))
.filter(x => !!x) ?? [];
const componentsMap = componentsExtensions.reduce(
(components, component) =>
component ? components.set(component.ref, component?.impl) : components,
new Map<ComponentRef<any>, any>(),
);
factoryRegistry.register('static', {
api: componentsApiRef,
deps: {},
factory: () => new DefaultComponentsApi(componentsMap),
factory: () => DefaultComponentsApi.fromTree(tree),
});
factoryRegistry.register('static', {