Merge pull request #32332 from backstage/rugvip/wrapper-v2

frontend-plugin-api: rework AppRootWrapperBlueprint
This commit is contained in:
Patrik Oldsberg
2026-01-20 12:07:19 +01:00
committed by GitHub
27 changed files with 695 additions and 7 deletions
+1
View File
@@ -4,6 +4,7 @@
Moved the following blueprints from `@backstage/frontend-plugin-api`:
- `AppRootWrapperBlueprint`
- `IconBundleBlueprint`
- `NavContentBlueprint`
- `RouterBlueprint`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Implemented support for the new `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api/alpha`.
+1
View File
@@ -4,6 +4,7 @@
The following blueprints are being restricted to only be used in app plugin overrides and modules. They are being moved to the `@backstage/plugin-app-react` package and have been deprecated:
- `AppRootWrapperBlueprint`
- `IconBundleBlueprint`
- `NavContentBlueprint`
- `RouterBlueprint`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added an alpha `PluginWrapperBlueprint` exported from `@backstage/frontend-plugin-api/alpha`, which can install components that will wrap all plugin elements.
+1
View File
@@ -4,6 +4,7 @@
The following blueprints are being restricted to only be used in app plugin overrides and modules. They will now produce a deprecation warning when used outside of the app plugin:
- `AppRootWrapperBlueprint`
- `IconBundleBlueprint`
- `NavContentBlueprint`
- `RouterBlueprint`
@@ -660,7 +660,7 @@ const convertedRootFeatures = convertLegacyAppRoot(
);
```
Any app root wrapper needs to be migrated to be an extension, created using `AppRootWrapperBlueprint`. Note that if you have multiple wrappers they must be completely independent of each other, i.e. the order in which they the appear in the React tree should not matter. If that is not the case then you should group them into a single wrapper.
Any app root wrapper needs to be migrated to be an extension, created using `AppRootWrapperBlueprint` from `@backstage/plugin-app-react`. Note that if you have multiple wrappers they must be completely independent of each other, i.e. the order in which they the appear in the React tree should not matter. If that is not the case then you should group them into a single wrapper.
Here is an example converting the `CustomAppBarrier` into extension:
@@ -23,6 +23,12 @@ Navigation item extensions are used to provide menu items that link to different
Page extensions provide content for a particular route in the app. By default pages are attached to the app routes extensions, which renders the root routes.
## Extension blueprints in `@backstage/frontend-plugin-api/alpha`
### Plugin Wrapper - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.packages-frontend-plugin-api_src_alpha.PluginWrapperBlueprint.html)
Plugin wrappers allow you to install components that will wrap all elements rendered as part of a plugin. This can be useful if you need to add a global provider, for example for a query client. The provided wrapper will be rendered as separate elements for each wrapped plugin element, so be sure to use a central store like a [Utility API](../utility-apis/01-index.md) if you want to share state between wrapper instances.
## Extension blueprints in `@backstage/plugin-app-react`
### SignInPage - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.SignInPageBlueprint.html)
@@ -392,6 +392,7 @@ describe('createApp', () => {
</api:app/swappable-components>
<api:app/icons out=[core.api.factory] />
<api:app/feature-flags out=[core.api.factory] />
<api:app/plugin-wrapper out=[core.api.factory] />
<api:app/translations out=[core.api.factory] />
<api:app/components out=[core.api.factory] />
]
@@ -16,12 +16,16 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
@@ -0,0 +1,68 @@
## API Report File for "@backstage/frontend-plugin-api"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
// @alpha
export type PluginWrapperApi = {
getPluginWrapper(pluginId: string):
| ComponentType<{
children: ReactNode;
}>
| undefined;
};
// @alpha
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi>;
// @alpha
export const PluginWrapperBlueprint: ExtensionBlueprint<{
kind: 'plugin-wrapper';
params: (params: {
loader: () => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>;
}) => ExtensionBlueprintParams<{
loader: () => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>;
}>;
output: ExtensionDataRef<
() => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>,
'core.plugin-wrapper.loader',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
wrapper: ConfigurableExtensionDataRef<
() => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>,
'core.plugin-wrapper.loader',
{}
>;
};
}>;
// (No @packageDocumentation comment for this package)
```
+1 -1
View File
@@ -271,7 +271,7 @@ export const AppRootElementBlueprint: ExtensionBlueprint_2<{
dataRefs: never;
}>;
// @public
// @public @deprecated
export const AppRootWrapperBlueprint: ExtensionBlueprint_2<{
kind: 'app-root-wrapper';
params: {
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint';
export {
type PluginWrapperApi,
pluginWrapperApiRef,
} from './apis/definitions/PluginWrapperApi';
@@ -0,0 +1,50 @@
/*
* 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 { ComponentType, ReactNode } from 'react';
import { createApiRef } from '@backstage/frontend-plugin-api';
/**
* The Plugin Wrapper API is used to wrap plugin extensions with providers,
* plugins should generally use `ExtensionBoundary` instead.
*
* @remarks
*
* This API is primarily intended for internal use by the Backstage frontend
* system, but can be used for advanced use-cases. If you do override it, be
* sure to include the default implementation as well.
*
* @alpha
*/
export type PluginWrapperApi = {
/**
* Returns a wrapper component for a specific plugin, or undefined if no
* wrappers exist. Do not use this API directly, instead use
* `ExtensionBoundary` to wrap your plugin components if needed.
*/
getPluginWrapper(
pluginId: string,
): ComponentType<{ children: ReactNode }> | undefined;
};
/**
* The API reference of {@link PluginWrapperApi}.
*
* @alpha
*/
export const pluginWrapperApiRef = createApiRef<PluginWrapperApi>({
id: 'core.plugin-wrapper.alpha',
});
@@ -27,6 +27,9 @@ const componentDataRef = createExtensionDataRef<
* and similar.
*
* @public
* @deprecated Use {@link @backstage/plugin-app-react#AppRootWrapperBlueprint} instead.
* If you were using this blueprint to provide a context for your plugin,
* use `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api/alpha` instead.
*/
export const AppRootWrapperBlueprint = createExtensionBlueprint({
kind: 'app-root-wrapper',
@@ -0,0 +1,50 @@
/*
* 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 { ComponentType, ReactNode } from 'react';
import {
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '../wiring';
const wrapperDataRef = createExtensionDataRef<
() => Promise<{ component: ComponentType<{ children: ReactNode }> }>
>().with({ id: 'core.plugin-wrapper.loader' });
/**
* Creates extensions that wrap plugin extensions with providers.
*
* @alpha
*/
export const PluginWrapperBlueprint = createExtensionBlueprint({
kind: 'plugin-wrapper',
attachTo: { id: 'api:app/plugin-wrapper', input: 'wrappers' },
output: [wrapperDataRef],
dataRefs: {
wrapper: wrapperDataRef,
},
defineParams(params: {
loader: () => Promise<{
component: ComponentType<{ children: ReactNode }>;
}>;
}) {
return createExtensionBlueprintParams(params);
},
*factory(params) {
yield wrapperDataRef(params.loader);
},
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useEffect } from 'react';
import { useEffect, ReactNode } from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import { TestApiProvider, withLogCollector } from '@backstage/test-utils';
import { ExtensionBoundary } from './ExtensionBoundary';
@@ -26,6 +26,10 @@ import {
createExtensionTester,
renderInTestApp,
} from '@backstage/frontend-test-utils';
import {
pluginWrapperApiRef,
PluginWrapperApi,
} from '../apis/definitions/PluginWrapperApi';
const wrapInBoundaryExtension = (element?: JSX.Element) => {
const routeRef = createRouteRef();
@@ -121,6 +125,85 @@ describe('ExtensionBoundary', () => {
});
});
it('should wrap children with PluginWrapper when provided', async () => {
const text = 'Wrapped Content';
const TextComponent = () => {
return <p>{text}</p>;
};
const WrapperComponent = ({ children }: { children: ReactNode }) => {
return (
<div data-testid="plugin-wrapper">
<span>Wrapper</span>
{children}
</div>
);
};
const pluginWrapperApi: PluginWrapperApi = {
getPluginWrapper: jest.fn((pluginId: string) => {
if (pluginId === 'app') {
return WrapperComponent;
}
return undefined;
}),
};
renderInTestApp(
<TestApiProvider apis={[[pluginWrapperApiRef, pluginWrapperApi]]}>
{createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).reactElement()}
</TestApiProvider>,
);
expect(await screen.findByTestId('plugin-wrapper')).toBeInTheDocument();
expect(screen.getByText('Wrapper')).toBeInTheDocument();
expect(screen.getByText(text)).toBeInTheDocument();
expect(pluginWrapperApi.getPluginWrapper).toHaveBeenCalledWith('app');
});
it('should handle errors thrown by PluginWrapper with ErrorDisplayBoundary', async () => {
const errorMsg = 'PluginWrapper error';
const TextComponent = () => {
return <p>Content</p>;
};
const ThrowingWrapper = () => {
throw new Error(errorMsg);
};
const pluginWrapperApi: PluginWrapperApi = {
getPluginWrapper: jest.fn((pluginId: string) => {
if (pluginId === 'app') {
return ThrowingWrapper;
}
return undefined;
}),
};
const { error } = await withLogCollector(['error'], async () => {
renderInTestApp(
<TestApiProvider apis={[[pluginWrapperApiRef, pluginWrapperApi]]}>
{createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).reactElement()}
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText(errorMsg)).toBeInTheDocument(),
);
});
expect(error).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(errorMsg),
}),
]),
);
});
// TODO(Rugvip): Need a way to be able to override APIs in the app to be able to test this properly
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should emit analytics events if routable', async () => {
@@ -19,6 +19,7 @@ import {
ReactNode,
Suspense,
useEffect,
useMemo,
lazy as reactLazy,
} from 'react';
import { AnalyticsContext, useAnalytics } from '../analytics';
@@ -27,6 +28,10 @@ import { ErrorApiBoundary } from './ErrorApiBoundary';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
import { AppNode, ErrorApi, errorApiRef, useApi } from '../apis';
import {
PluginWrapperApi,
pluginWrapperApiRef,
} from '../apis/definitions/PluginWrapperApi';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
import { Progress } from './DefaultSwappableComponents';
@@ -39,6 +44,13 @@ function useOptionalErrorApi(): ErrorApi | undefined {
}
}
function useOptionalPluginWrapperApi(): PluginWrapperApi | undefined {
try {
return useApi(pluginWrapperApiRef);
} catch {
return undefined;
}
}
type RouteTrackerProps = PropsWithChildren<{
enabled?: boolean;
}>;
@@ -78,11 +90,18 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
);
const plugin = node.spec.plugin;
const pluginId = plugin.id ?? 'app';
const pluginWrapperApi = useOptionalPluginWrapperApi();
const PluginWrapper = useMemo(() => {
return pluginWrapperApi?.getPluginWrapper(pluginId);
}, [pluginWrapperApi, pluginId]);
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
extensionId: node.spec.id,
pluginId: plugin.id ?? 'app',
pluginId,
};
let content = (
@@ -91,6 +110,10 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
</AnalyticsContext>
);
if (PluginWrapper) {
content = <PluginWrapper>{content}</PluginWrapper>;
}
if (props.errorPresentation === 'error-api') {
content = (
<ErrorApiBoundary node={node} errorApi={errorApi}>
+40 -2
View File
@@ -18,6 +18,30 @@ import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
import { TranslationMessages } from '@backstage/frontend-plugin-api';
import { TranslationResource } from '@backstage/frontend-plugin-api';
// @public
export const AppRootWrapperBlueprint: ExtensionBlueprint<{
kind: 'app-root-wrapper';
params: {
Component?: [error: 'Use the `component` parameter instead'];
component: (props: { children: ReactNode }) => JSX.Element | null;
};
output: ExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{}
>;
};
}>;
// @public
export const IconBundleBlueprint: ExtensionBlueprint<{
kind: 'icon-bundle';
@@ -136,7 +160,14 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element
/**
* Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin.
*
* @public
*/ | null
>)
: never;
}) => ExtensionBlueprintParams<{
component: Ref extends SwappableComponentRef<
@@ -150,7 +181,14 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element
/**
* Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin.
*
* @public
*/ | null
>)
: never;
}>;
output: ExtensionDataRef<
@@ -15,6 +15,7 @@
*/
import {
AppRootWrapperBlueprint as _AppRootWrapperBlueprint,
IconBundleBlueprint as _IconBundleBlueprint,
NavContentBlueprint as _NavContentBlueprint,
type NavContentComponent,
@@ -27,6 +28,14 @@ import {
TranslationBlueprint as _TranslationBlueprint,
} from '@backstage/frontend-plugin-api';
/**
* Creates an extension that renders a React wrapper at the app root, enclosing
* the app layout. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const AppRootWrapperBlueprint = _AppRootWrapperBlueprint;
/**
* Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin.
*
+31
View File
@@ -574,6 +574,37 @@ const appPlugin: OverridableFrontendPlugin<
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/plugin-wrapper': OverridableExtensionDefinition<{
config: {};
configInput: {};
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
inputs: {
wrappers: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>,
'core.plugin-wrapper.loader',
{}
>,
{
singleton: false;
optional: false;
}
>;
};
kind: 'api';
name: 'plugin-wrapper';
params: <
TApi,
TImpl extends TApi,
TDeps extends { [name in string]: unknown },
>(
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/scm-auth': OverridableExtensionDefinition<{
kind: 'api';
name: 'scm-auth';
@@ -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,109 @@
/*
* 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/alpha';
import { ComponentType, ReactNode, useEffect, useMemo, 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;
}
return useMemo(() => {
if (!loadedWrappers) {
return null;
}
let current = props.children;
for (const Wrapper of loadedWrappers) {
current = <Wrapper>{current}</Wrapper>;
}
return current;
}, [loadedWrappers, props.children]);
};
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';
+11 -1
View File
@@ -117,7 +117,17 @@ export const AppRoot = createExtension({
for (const wrapper of inputs.wrappers) {
const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component);
content = <Component>{content}</Component>;
const pluginId = wrapper.node.spec.plugin.id;
if (Component) {
content = <Component>{content}</Component>;
if (pluginId !== 'app') {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: AppRootWrappers should only be installed as an extension in the app plugin. ` +
`You can either use appPlugin.override(), or a module for the app plugin. The following extension will be ignored in the future: ${wrapper.node.spec.id}`,
);
}
}
}
return [
@@ -0,0 +1,51 @@
/*
* 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,
pluginWrapperApiRef,
} from '@backstage/frontend-plugin-api/alpha';
import {
createExtensionInput,
ApiBlueprint,
} 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';
+2
View File
@@ -28,6 +28,7 @@ import {
SwappableComponentsApi,
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
TranslationsApi,
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
@@ -58,6 +59,7 @@ export const appPlugin = createFrontendPlugin({
SwappableComponentsApi,
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
TranslationsApi,
DefaultSignInPage,
oauthRequestDialogAppRootElement,