added new app-react library with AppRootWrapperBlueprint replacement

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-15 12:17:21 +01:00
parent b164e20697
commit 0b494408a6
8 changed files with 265 additions and 9 deletions
+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: {
@@ -27,6 +27,10 @@ const componentDataRef = createExtensionDataRef<
* and similar.
*
* @public
* @deprecated Use {@link PluginWrapperBlueprint} instead if you want to wrap
* all plugin components in the same wrapper. If you want to wrap the entire
* app, use the `AppRootWrapperBlueprint` from `@backstage/plugin-app-react`
* instead, although note that only app modules are able to use that blueprint.
*/
export const AppRootWrapperBlueprint = createExtensionBlueprint({
kind: 'app-root-wrapper',
+27
View File
@@ -18,6 +18,33 @@ 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: (props: { children: ReactNode }) => JSX.Element | null;
};
output: ExtensionDataRef<
ComponentType<{
children: ReactNode;
}>,
'app.root-wrapper-component',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
ComponentType<{
children: ReactNode;
}>,
'app.root-wrapper-component',
{}
>;
};
}>;
// @public
export const IconBundleBlueprint: ExtensionBlueprint<{
kind: 'icon-bundle';
@@ -0,0 +1,138 @@
/*
* 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 { Fragment } from 'react';
import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
import { screen, waitFor } from '@testing-library/react';
import {
coreExtensionData,
createExtension,
createExtensionInput,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { renderTestApp } from '@backstage/frontend-test-utils';
describe('AppRootWrapperBlueprint', () => {
it('should return an extension with sensible defaults', () => {
const extension = AppRootWrapperBlueprint.make({
params: {
component: () => <div>Hello</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/root",
"input": "wrappers",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "app-root-wrapper",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should render a simple component wrapper', async () => {
const extension = AppRootWrapperBlueprint.make({
name: 'test',
params: {
component: () => <div>Hello</div>,
},
});
renderTestApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [extension],
}),
],
});
await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument());
});
it('should render a complex component wrapper', async () => {
const extension = AppRootWrapperBlueprint.makeWithOverrides({
config: {
schema: {
name: z => z.string(),
},
},
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
*factory(originalFactory, { inputs, config }) {
yield* originalFactory({
component: ({ children }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{inputs.children.flatMap((c, index) => (
<Fragment key={index}>
{c.get(coreExtensionData.reactElement)}
</Fragment>
))}
{children}
</div>
),
});
},
});
renderTestApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
extension,
createExtension({
name: 'test-child',
attachTo: extension.inputs.children,
output: [coreExtensionData.reactElement],
factory: () => [
coreExtensionData.reactElement(<div>Its Me</div>),
],
}),
],
}),
],
config: {
app: {
extensions: [
{
'app-root-wrapper:app': { config: { name: 'Robin' } },
},
],
},
},
});
await waitFor(() => {
expect(screen.getByTestId('Robin-1')).toBeInTheDocument();
expect(screen.getByText('Its Me')).toBeInTheDocument();
});
});
});
@@ -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 { ComponentType, ReactNode } from 'react';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const componentDataRef = createExtensionDataRef<
ComponentType<{ children: ReactNode }>
>().with({ id: 'app.root-wrapper-component' });
/**
* Creates an extension that renders a React wrapper at the app root, enclosing
* the app layout.
*
* @remarks
*
* This is useful for example for adding global React contexts and similar. This
* extension is only available for use by app modules, plugins should use the
* {@link @backstage/frontend-plugin-api#PluginWrapperBlueprint} instead.
*
* @public
*/
export const AppRootWrapperBlueprint = createExtensionBlueprint({
kind: 'app-root-wrapper',
attachTo: { id: 'app/root', input: 'wrappers' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(params: {
component: (props: { children: ReactNode }) => JSX.Element | null;
}) {
yield componentDataRef(params.component);
},
});
@@ -27,6 +27,8 @@ import {
TranslationBlueprint as _TranslationBlueprint,
} from '@backstage/frontend-plugin-api';
export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
/**
* Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin.
*
+16 -5
View File
@@ -149,11 +149,22 @@ const appPlugin: OverridableFrontendPlugin<
}
>;
wrappers: ExtensionInput<
ConfigurableExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{}
>,
| ConfigurableExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
ComponentType<{
children: ReactNode;
}>,
'app.root-wrapper-component',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
+26 -3
View File
@@ -22,7 +22,7 @@ import {
JSX,
} from 'react';
import {
AppRootWrapperBlueprint,
AppRootWrapperBlueprint as OldAppRootWrapperBlueprint,
RouterBlueprint,
SignInPageBlueprint,
coreExtensionData,
@@ -44,6 +44,7 @@ import {
identityApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isProtectedApp } from '../../../../packages/core-app-api/src/app/isProtectedApp';
import { BrowserRouter } from 'react-router-dom';
@@ -69,7 +70,8 @@ export const AppRoot = createExtension({
}),
elements: createExtensionInput([coreExtensionData.reactElement]),
wrappers: createExtensionInput([
AppRootWrapperBlueprint.dataRefs.component,
OldAppRootWrapperBlueprint.dataRefs.component.optional(),
AppRootWrapperBlueprint.dataRefs.component.optional(),
]),
},
output: [coreExtensionData.reactElement],
@@ -117,7 +119,28 @@ 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) {
if (pluginId === 'app') {
content = <Component>{content}</Component>;
} else {
// eslint-disable-next-line no-console
console.warn(
`Warning: The app root wrapper extension from the '${pluginId}' plugin was ignored, only the 'app' plugin is allowed to provide app root wrappers.`,
);
}
} else {
const OldComponent = wrapper.get(
OldAppRootWrapperBlueprint.dataRefs.component,
);
if (OldComponent) {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: The ${pluginId} plugin provided a deprecated app root wrapper extension using the AppRootWrapperBlueprint from @backstage/frontend-plugin-api. This will break in a future release.`,
);
content = <OldComponent>{content}</OldComponent>;
}
}
}
return [