frontend-plugin-api: complete deprecation of app blueprints

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-23 01:31:30 +01:00
parent 7edb810248
commit c38b74df5f
49 changed files with 178 additions and 475 deletions
+23 -22
View File
@@ -10,10 +10,9 @@ import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { NavContentComponent } from '@backstage/frontend-plugin-api';
import { NavContentComponentProps } from '@backstage/frontend-plugin-api';
import { IdentityApi } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { SignInPageProps } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
import { TranslationMessages } from '@backstage/frontend-plugin-api';
import { TranslationResource } from '@backstage/frontend-plugin-api';
@@ -92,9 +91,21 @@ export const NavContentBlueprint: ExtensionBlueprint<{
};
}>;
export { NavContentComponent };
// @public
export type NavContentComponent = (
props: NavContentComponentProps,
) => JSX.Element | null;
export { NavContentComponentProps };
// @public
export interface NavContentComponentProps {
items: Array<{
icon: IconComponent;
title: string;
routeRef: RouteRef<undefined>;
to: string;
text: string;
}>;
}
// @public
export const RouterBlueprint: ExtensionBlueprint<{
@@ -143,7 +154,11 @@ export const SignInPageBlueprint: ExtensionBlueprint<{
};
}>;
export { SignInPageProps };
// @public
export type SignInPageProps = {
onSignInSuccess(identityApi: IdentityApi): void;
children?: ReactNode;
};
// @public
export const SwappableComponentBlueprint: ExtensionBlueprint<{
@@ -160,14 +175,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (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
>)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}) => ExtensionBlueprintParams<{
component: Ref extends SwappableComponentRef<
@@ -181,14 +189,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (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
>)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}>;
output: ExtensionDataRef<
@@ -0,0 +1,123 @@
/*
* Copyright 2026 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,
} 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 the simple component wrapper', async () => {
const extension = AppRootWrapperBlueprint.make({
name: 'test',
params: {
component: () => <div>Hello</div>,
},
});
renderTestApp({ extensions: [extension] });
await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument());
});
it('should render the 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}`}>
{children}
{inputs.children.flatMap((c, index) => (
<Fragment key={index}>
{c.get(coreExtensionData.reactElement)}
</Fragment>
))}
</div>
),
});
},
});
renderTestApp({
extensions: [
extension,
createExtension({
name: 'test-child',
attachTo: { id: 'app-root-wrapper:test', input: 'children' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>Its Me</div>)],
}),
],
config: {
app: {
extensions: [
{
'app-root-wrapper:test': { config: { name: 'Robin' } },
},
],
},
},
});
await waitFor(() => {
expect(screen.getByTestId('Robin-1')).toBeInTheDocument();
expect(screen.getByText('Its Me')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2026 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 { ReactNode } from 'react';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const componentDataRef = createExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null
>().with({ id: 'app.root.wrapper' });
/**
* Creates a extensions that render a React wrapper at the app root, enclosing
* the app layout. This is useful for example for adding global React contexts
* and similar. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const AppRootWrapperBlueprint = createExtensionBlueprint({
kind: 'app-root-wrapper',
attachTo: { id: 'app/root', input: 'wrappers' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(params: {
/** @deprecated use the `component` parameter instead */
Component?: [error: 'Use the `component` parameter instead'];
component: (props: { children: ReactNode }) => JSX.Element | null;
}) {
yield componentDataRef(params.component);
},
});
@@ -0,0 +1,42 @@
/*
* Copyright 2026 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 { IconComponent } from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const iconsDataRef = createExtensionDataRef<{
[key in string]: IconComponent;
}>().with({ id: 'core.icons' });
/**
* Creates an extension that adds icon bundles to your app. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const IconBundleBlueprint = createExtensionBlueprint({
kind: 'icon-bundle',
attachTo: { id: 'api:app/icons', input: 'icons' },
output: [iconsDataRef],
factory: (params: { icons: { [key in string]: IconComponent } }) => [
iconsDataRef(params.icons),
],
dataRefs: {
icons: iconsDataRef,
},
});
@@ -0,0 +1,112 @@
/*
* 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 { createRouteRef } from '@backstage/frontend-plugin-api';
import { NavContentBlueprint } from './NavContentBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
const routeRef = createRouteRef();
describe('NavContentBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = NavContentBlueprint.make({
params: {
component: () => <div>Nav content</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/nav",
"input": "content",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "nav-content",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return a valid component', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: () => <div>Nav content</div>,
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }),
).toEqual(<div>Nav content</div>);
});
it('should return a valid component with items', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: ({ items }) => (
<div>
Items:
{items.map((item, index) => (
<a key={index} href={item.to}>
{item.title}
</a>
))}
</div>
),
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({
items: [
{
to: '/',
text: 'Home',
title: 'Home',
icon: () => null,
routeRef,
},
],
}),
).toEqual(
<div>
Items:
{[
<a key={0} href="/">
Home
</a>,
]}
</div>,
);
});
});
@@ -0,0 +1,77 @@
/*
* Copyright 2026 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 { IconComponent, RouteRef } from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
/**
* The props for the {@link NavContentComponent}.
*
* @public
*/
export interface NavContentComponentProps {
/**
* The nav items available to the component. These are all the items created
* with the {@link @backstage/frontend-plugin-api#NavItemBlueprint} in the app.
*
* In addition to the original properties from the nav items, these also
* include a resolved route path as `to`, and duplicated `title` as `text` to
* simplify rendering.
*/
items: Array<{
// Original props from nav items
icon: IconComponent;
title: string;
routeRef: RouteRef<undefined>;
// Additional props to simplify item rendering
to: string;
text: string;
}>;
}
/**
* A component that renders the nav bar content, to be passed to the {@link NavContentBlueprint}.
*
* @public
*/
export type NavContentComponent = (
props: NavContentComponentProps,
) => JSX.Element | null;
const componentDataRef = createExtensionDataRef<NavContentComponent>().with({
id: 'core.nav-content.component',
});
/**
* Creates an extension that replaces the entire nav bar with your own component. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const NavContentBlueprint = createExtensionBlueprint({
kind: 'nav-content',
attachTo: { id: 'app/nav', input: 'content' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(params: { component: NavContentComponent }) {
yield componentDataRef(params.component);
},
});
@@ -0,0 +1,137 @@
/*
* 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 { RouterBlueprint } from './RouterBlueprint';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import {
coreExtensionData,
createExtension,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('RouterBlueprint', () => {
it('should return an extension when calling make with sensible defaults', () => {
const extension = RouterBlueprint.make({
params: {
component: props => <div>{props.children}</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/root",
"input": "router",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "app-router-component",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should work with simple options', async () => {
const extension = RouterBlueprint.make({
params: {
component: ({ children }) => (
<MemoryRouter>
<div data-testid="test-router">{children}</div>
</MemoryRouter>
),
},
});
const tester = createExtensionTester(extension);
const Component = tester.get(RouterBlueprint.dataRefs.component);
const { getByTestId } = render(
<Component>
<div data-testid="test-contents" />
</Component>,
);
await waitFor(() => {
expect(getByTestId('test-contents')).toBeInTheDocument();
expect(getByTestId('test-router')).toBeInTheDocument();
});
});
it('should work with complex options and props', async () => {
const extension = RouterBlueprint.makeWithOverrides({
name: 'test',
config: {
schema: {
name: z => z.string(),
},
},
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
*factory(originalFactory, { inputs, config }) {
yield* originalFactory({
component: ({ children }) => (
<MemoryRouter>
<div
data-testid={`test-router-${config.name}-${inputs.children.length}`}
>
{children}
</div>
</MemoryRouter>
),
});
},
});
const tester = createExtensionTester(extension, {
config: { name: 'Robin' },
}).add(
createExtension({
attachTo: {
id: 'app-router-component:test',
input: 'children',
},
output: [coreExtensionData.reactElement],
*factory() {
yield coreExtensionData.reactElement(<div />);
},
}),
);
const Component = tester.get(RouterBlueprint.dataRefs.component);
const { getByTestId } = render(
<Component>
<div data-testid="test-contents" />
</Component>,
);
await waitFor(() => {
expect(getByTestId('test-contents')).toBeInTheDocument();
expect(getByTestId('test-router-Robin-1')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2026 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 { ReactNode } from 'react';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const componentDataRef = createExtensionDataRef<
(props: { children: ReactNode }) => JSX.Element | null
>().with({ id: 'app.router.wrapper' });
/**
* Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const RouterBlueprint = createExtensionBlueprint({
kind: 'app-router-component',
attachTo: { id: 'app/root', input: 'router' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(params: {
/** @deprecated use the `component` parameter instead */
Component?: [error: 'Use the `component` parameter instead'];
component: (props: { children: ReactNode }) => JSX.Element | null;
}) {
yield componentDataRef(params.component);
},
});
@@ -0,0 +1,71 @@
/*
* 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 { SignInPageBlueprint } from './SignInPageBlueprint';
import {
createExtensionTester,
renderInTestApp,
} from '@backstage/frontend-test-utils';
import { screen, waitFor } from '@testing-library/react';
describe('SignInPageBlueprint', () => {
it('should create an extension with sensible defaults', () => {
expect(
SignInPageBlueprint.make({
params: { loader: async () => () => <div /> },
}),
).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/root",
"input": "signInPage",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "sign-in-page",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the component as the componentRef', async () => {
const MockSignInPage = () => <div data-testid="mock-sign-in" />;
const extension = SignInPageBlueprint.make({
params: { loader: async () => () => <MockSignInPage /> },
});
const tester = createExtensionTester(extension);
const Component = tester.get(SignInPageBlueprint.dataRefs.component);
renderInTestApp(<Component onSignInSuccess={() => {}} />);
await waitFor(() => {
expect(screen.getByTestId('mock-sign-in')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,76 @@
/*
* Copyright 2026 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, lazy, ReactNode } from 'react';
import {
createExtensionBlueprint,
createExtensionDataRef,
ExtensionBoundary,
IdentityApi,
} from '@backstage/frontend-plugin-api';
/**
* Props for the `SignInPage` component.
*
* @public
*/
export type SignInPageProps = {
/**
* Set the IdentityApi on successful sign-in. This should only be called once.
*/
onSignInSuccess(identityApi: IdentityApi): void;
/**
* The children to render.
*/
children?: ReactNode;
};
const componentDataRef = createExtensionDataRef<
ComponentType<SignInPageProps>
>().with({ id: 'core.sign-in-page.component' });
/**
* Creates an extension that replaces the sign in page. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const SignInPageBlueprint = createExtensionBlueprint({
kind: 'sign-in-page',
attachTo: { id: 'app/root', input: 'signInPage' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(
{
loader,
}: {
loader: () => Promise<ComponentType<SignInPageProps>>;
},
{ node },
) {
const ExtensionComponent = lazy(() =>
loader().then(component => ({ default: component })),
);
yield componentDataRef(props => (
<ExtensionBoundary node={node}>
<ExtensionComponent {...props} />
</ExtensionBoundary>
));
},
});
@@ -0,0 +1,135 @@
/*
* Copyright 2025 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 { renderTestApp } from '@backstage/frontend-test-utils';
import {
createSwappableComponent,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
import { screen } from '@testing-library/react';
describe('SwappableComponentBlueprint', () => {
it('should allow defining a component override for a component ref', () => {
const Component = createSwappableComponent({
id: 'test.component',
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
});
const extension = SwappableComponentBlueprint.make({
params: define =>
define({
component: Component,
loader: () => props => {
// @ts-expect-error
const t: number = props.hello;
return <div>Override {props.hello}</div>;
},
}),
});
expect(extension).toBeDefined();
});
it('should render default component refs in the app', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
});
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
});
await expect(screen.findByText('test!')).resolves.toBeInTheDocument();
});
it('should render a component ref without a default implementation', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
});
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
path: '/',
loader: async () => <TestComponent />,
}),
}),
],
});
await expect(
screen.findByTestId('test.component'),
).resolves.toBeInTheDocument();
});
it('should render a component ref with an async loader implementation', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: async () => (props: { hello: string }) =>
<div>{props.hello}</div>,
});
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
});
await expect(screen.findByText('test!')).resolves.toBeInTheDocument();
});
it('should render a component ref with an async loader implementation and prop transform', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: async () => (props: { hello: string }) =>
<div>{props.hello}</div>,
transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
});
renderTestApp({
extensions: [
PageBlueprint.make({
params: define =>
define({
path: '/',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
});
await expect(screen.findByText('tr test!')).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
/*
* Copyright 2026 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 { SwappableComponentRef } from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
export const componentDataRef = createExtensionDataRef<{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
}>().with({ id: 'core.swappableComponent' });
/**
* Blueprint for creating swappable components from a SwappableComponentRef and a loader. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const SwappableComponentBlueprint = createExtensionBlueprint({
kind: 'component',
attachTo: { id: 'api:app/swappable-components', input: 'components' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
defineParams<Ref extends SwappableComponentRef<any>>(params: {
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}) {
return createExtensionBlueprintParams(params);
},
factory: params => [
componentDataRef({
ref: params.component.ref,
loader: params.loader,
}),
],
});
@@ -0,0 +1,62 @@
/*
* 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 { AppTheme } from '@backstage/frontend-plugin-api';
import { ThemeBlueprint } from './ThemeBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('ThemeBlueprint', () => {
const theme = {
id: 'light',
colors: { primary: 'blue' },
variant: 'dark',
title: 'lols',
Provider: (_: { children: React.ReactNode }) => null,
} as AppTheme;
it('should create an extension with sensible defaults', () => {
expect(ThemeBlueprint.make({ name: 'light', params: { theme } }))
.toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "api:app/app-theme",
"input": "themes",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "theme",
"name": "light",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the theme as an themeDataRef', async () => {
const extension = ThemeBlueprint.make({ params: { theme } });
expect(
createExtensionTester(extension).get(ThemeBlueprint.dataRefs.theme),
).toEqual(theme);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2026 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 { AppTheme } from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const themeDataRef = createExtensionDataRef<AppTheme>().with({
id: 'core.theme.theme',
});
/**
* Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const ThemeBlueprint = createExtensionBlueprint({
kind: 'theme',
attachTo: { id: 'api:app/app-theme', input: 'themes' },
output: [themeDataRef],
dataRefs: {
theme: themeDataRef,
},
factory: ({ theme }: { theme: AppTheme }) => [themeDataRef(theme)],
});
@@ -0,0 +1,84 @@
/*
* 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import {
createTranslationMessages,
createTranslationRef,
} from '@backstage/frontend-plugin-api';
import { TranslationBlueprint } from './TranslationBlueprint';
describe('TranslationBlueprint', () => {
const translationRef = createTranslationRef({
id: 'translationRefId',
messages: {
test: 'test',
},
});
const messages = createTranslationMessages({
ref: translationRef,
messages: {
test: 'test2',
},
});
it('should return an extension instance with sane defaults', () => {
expect(
TranslationBlueprint.make({
name: 'blob',
params: {
resource: messages,
},
}),
).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "api:app/translations",
"input": "translations",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "translation",
"name": "blob",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should output a translation data ref', () => {
const extension = TranslationBlueprint.make({
name: 'blob',
params: {
resource: messages,
},
});
expect(
createExtensionTester(extension).get(
TranslationBlueprint.dataRefs.translation,
),
).toBe(messages);
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2026 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 {
createExtensionBlueprint,
createExtensionDataRef,
TranslationMessages,
TranslationResource,
} from '@backstage/frontend-plugin-api';
const translationDataRef = createExtensionDataRef<
TranslationResource | TranslationMessages
>().with({ id: 'core.translation.translation' });
/**
* Creates an extension that adds translations to your app. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const TranslationBlueprint = createExtensionBlueprint({
kind: 'translation',
attachTo: { id: 'api:app/translations', input: 'translations' },
output: [translationDataRef],
dataRefs: {
translation: translationDataRef,
},
factory: ({
resource,
}: {
resource: TranslationResource | TranslationMessages;
}) => [translationDataRef(resource)],
});
+13 -91
View File
@@ -14,94 +14,16 @@
* limitations under the License.
*/
import {
AppRootWrapperBlueprint as _AppRootWrapperBlueprint,
IconBundleBlueprint as _IconBundleBlueprint,
NavContentBlueprint as _NavContentBlueprint,
type NavContentComponent,
type NavContentComponentProps,
RouterBlueprint as _RouterBlueprint,
SignInPageBlueprint as _SignInPageBlueprint,
type SignInPageProps,
SwappableComponentBlueprint as _SwappableComponentBlueprint,
ThemeBlueprint as _ThemeBlueprint,
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.
*
* @public
*/
export const ThemeBlueprint = _ThemeBlueprint;
/**
* Blueprint for creating swappable components from a SwappableComponentRef and a loader. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const SwappableComponentBlueprint = _SwappableComponentBlueprint;
/**
* Creates an extension that replaces the sign in page. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const SignInPageBlueprint = _SignInPageBlueprint;
/**
* Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const RouterBlueprint = _RouterBlueprint;
/**
* Creates an extension that replaces the entire nav bar with your own component. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const NavContentBlueprint = _NavContentBlueprint;
/**
* Creates an extension that adds icon bundles to your app. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const IconBundleBlueprint = _IconBundleBlueprint;
/**
* Props for the `SignInPage` component.
*
* @public
*/
export type { SignInPageProps };
/**
* The props for the {@link NavContentComponent}.
*
* @public
*/
export type { NavContentComponentProps };
/**
* A component that renders the nav bar content, to be passed to the {@link NavContentBlueprint}.
*
* @public
*/
export type { NavContentComponent };
/**
* Creates an extension that adds translations to your app. This blueprint is limited to use by the app plugin.
*
* @public
*/
export const TranslationBlueprint = _TranslationBlueprint;
export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
export { IconBundleBlueprint } from './IconBundleBlueprint';
export { NavContentBlueprint } from './NavContentBlueprint';
export type {
NavContentComponent,
NavContentComponentProps,
} from './NavContentBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
export type { SignInPageProps } from './SignInPageBlueprint';
export { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
export { ThemeBlueprint } from './ThemeBlueprint';
export { TranslationBlueprint } from './TranslationBlueprint';