frontend-plugin-api: added PluginWrapperBlueprint + implementation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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',
|
||||
})),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -32,3 +32,4 @@ export {
|
||||
alertDisplayAppRootElement,
|
||||
} from './elements';
|
||||
export { Progress, NotFoundErrorPage, ErrorDisplay } from './components';
|
||||
export { PluginWrapperApi } from './PluginWrapperApi';
|
||||
|
||||
Reference in New Issue
Block a user