frontend-plugin-api: added PluginWrapperBlueprint + implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-15 11:40:36 +01:00
parent c4e03c31ac
commit b164e20697
11 changed files with 448 additions and 1 deletions
@@ -0,0 +1,98 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi';
describe('DefaultPluginWrapperApi', () => {
it('should wrap multiple components with a single wrapper', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async () => ({
component: ({ children }) => <>Wrapper({children})</>,
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
const Wrapper3 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
expect(Wrapper3).toBeDefined();
render(
<>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
<div>
<Wrapper3>3</Wrapper3>
</div>
</>,
);
await expect(screen.findByText('Wrapper(1)')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper(2)')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper(3)')).resolves.toBeInTheDocument();
});
it('should wrap multiple components with multiple wrappers', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async () => ({
component: ({ children }) => <>WrapperA({children})</>,
}),
pluginId: 'plugin-1',
},
{
loader: async () => ({
component: ({ children }) => <>WrapperB({children})</>,
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
render(
<>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
</>,
);
await expect(
screen.findByText('WrapperB(WrapperA(1))'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('WrapperB(WrapperA(2))'),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
/*
* 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 { PluginWrapperApi } from '@backstage/frontend-plugin-api';
import { ComponentType, ReactNode, useEffect, useState } from 'react';
type WrapperInput = {
loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>;
pluginId: string;
};
/**
* Default implementation of PluginWrapperApi.
*
* @internal
*/
export class DefaultPluginWrapperApi implements PluginWrapperApi {
constructor(
private readonly pluginWrappers: Map<
string,
ComponentType<{ children: ReactNode }>
>,
) {}
getPluginWrapper(
pluginId: string,
): ComponentType<{ children: ReactNode }> | undefined {
return this.pluginWrappers.get(pluginId);
}
static fromWrappers(wrappers: Array<WrapperInput>): DefaultPluginWrapperApi {
const loadersByPlugin = new Map<
string,
Array<
() => Promise<{ component: ComponentType<{ children: ReactNode }> }>
>
>();
for (const wrapper of wrappers) {
let loaders = loadersByPlugin.get(wrapper.pluginId);
if (!loaders) {
loaders = [];
loadersByPlugin.set(wrapper.pluginId, loaders);
}
loaders.push(wrapper.loader);
}
const composedWrappers = new Map<
string,
ComponentType<{ children: ReactNode }>
>();
for (const [pluginId, loaders] of loadersByPlugin) {
if (loaders.length === 0) {
continue;
}
const ComposedWrapper = (props: { children: ReactNode }) => {
const [loadedWrappers, setLoadedWrappers] = useState<
Array<ComponentType<{ children: ReactNode }>> | undefined
>(undefined);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
Promise.all(loaders.map(loader => loader()))
.then(results => {
setLoadedWrappers(results.map(r => r.component));
})
.catch(setError);
}, []);
if (error) {
throw error;
}
if (!loadedWrappers) {
return null;
}
let content = props.children;
for (const Wrapper of loadedWrappers) {
content = <Wrapper>{content}</Wrapper>;
}
return <>{content}</>;
};
composedWrappers.set(pluginId, ComposedWrapper);
}
return new DefaultPluginWrapperApi(composedWrappers);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi';
@@ -0,0 +1,49 @@
/*
* 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 {
PluginWrapperBlueprint,
createExtensionInput,
ApiBlueprint,
pluginWrapperApiRef,
} from '@backstage/frontend-plugin-api';
import { DefaultPluginWrapperApi } from '../apis/PluginWrapperApi';
/**
* Contains the plugin wrappers installed into the app.
*/
export const PluginWrapperApi = ApiBlueprint.makeWithOverrides({
name: 'plugin-wrapper',
inputs: {
wrappers: createExtensionInput([PluginWrapperBlueprint.dataRefs.wrapper]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
defineParams({
api: pluginWrapperApiRef,
deps: {},
factory: () => {
return DefaultPluginWrapperApi.fromWrappers(
inputs.wrappers.map(wrapperInput => ({
loader: wrapperInput.get(PluginWrapperBlueprint.dataRefs.wrapper),
pluginId: wrapperInput.node.spec.plugin.id ?? 'app',
})),
);
},
}),
);
},
});
+1
View File
@@ -32,3 +32,4 @@ export {
alertDisplayAppRootElement,
} from './elements';
export { Progress, NotFoundErrorPage, ErrorDisplay } from './components';
export { PluginWrapperApi } from './PluginWrapperApi';