Merge pull request #28619 from backstage/rugvip/multi

frontend-plugin-api: add support for multiple attachTos
This commit is contained in:
Patrik Oldsberg
2025-02-10 15:55:05 +01:00
committed by GitHub
14 changed files with 281 additions and 66 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-plugin-api': patch
'@backstage/frontend-app-api': patch
---
Add support for defining multiple attachment points for extensions and blueprints.
@@ -337,3 +337,21 @@ const routableExtension = createExtension({
},
});
```
## Multiple attachment points
For some cases it can be useful to attach extensions to multiple parents. An example of this are Scaffolder field extensions or TechDocs addons that are consumed by multiple extensions. Specifying multiple attachments is done by providing an array of attachment points to the `attachTo` property of the extension. Keep in mind that this increases the complexity of your extension tree and should only be done when necessary. The following example shows how to attach our example extension to multiple parents:
```tsx
const extension = createExtension({
name: 'my-extension',
attachTo: [
{ id: 'my-first-parent', input: 'content' },
{ id: 'my-second-parent', input: 'children' }, // The input names do not need to match
],
output: [coreExtensionData.reactElement],
factory() {
return [coreExtensionData.reactElement(<div>Hello World</div>)];
},
});
```
@@ -119,6 +119,56 @@ describe('buildAppTree', () => {
`);
});
it('should create a tree with clones', () => {
const tree = resolveAppTree('a', [
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
{
...baseSpec,
id: 'c',
attachTo: [
{ id: 'a', input: 'x' },
{ id: 'b', input: 'x' },
],
},
{
...baseSpec,
id: 'd',
attachTo: [
{ id: 'b', input: 'x' },
{ id: 'c', input: 'x' },
],
},
]);
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']);
expect(String(tree.root)).toMatchInlineSnapshot(`
"<a>
x [
<b>
x [
<c>
x [
<d />
]
</c>
<d />
]
</b>
<c>
x [
<d />
]
</c>
]
</a>"
`);
const orphans = Array.from(tree.orphans).map(String);
expect(orphans).toMatchInlineSnapshot(`[]`);
});
it('should create a tree out of order', () => {
const tree = resolveAppTree('b', [
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
@@ -239,30 +289,30 @@ describe('buildAppTree', () => {
]);
expect(tree.root).toMatchInlineSnapshot(`
{
"attachments": {
"test": [
{
"attachments": undefined,
"id": "b",
"output": undefined,
},
],
},
"id": "a",
"output": undefined,
}
`);
{
"attachments": {
"test": [
{
"attachments": undefined,
"id": "b",
"output": undefined,
},
],
},
"id": "a",
"output": undefined,
}
`);
expect(tree.orphans).toMatchInlineSnapshot(`[]`);
expect(String(tree.root)).toMatchInlineSnapshot(`
"<a>
test [
<b />
]
</a>"
`);
"<a>
test [
<b />
]
</a>"
`);
});
it('should not allow redirects for attachment points that already exist', () => {
@@ -145,6 +145,7 @@ export function resolveAppTree(
}
const orphans = new Array<SerializableAppNode>();
const clones = new Map<string, Array<SerializableAppNode>>();
// A node with the provided rootNodeId must be found in the tree, and it must not be attached to anything
let rootNode: AppNode | undefined = undefined;
@@ -155,9 +156,42 @@ export function resolveAppTree(
// TODO: For now we simply ignore the attachTo spec of the root node, but it'd be cleaner if we could avoid defining it
if (spec.id === rootNodeId) {
rootNode = node;
} else {
let attachTo = node.spec.attachTo;
} else if (Array.isArray(spec.attachTo)) {
let foundFirstParent = false;
for (const origAttachTo of spec.attachTo) {
let attachTo = origAttachTo;
if (!isValidAttachmentPoint(attachTo, nodes)) {
attachTo =
redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
}
const parent = nodes.get(attachTo.id);
if (parent) {
const cloneParents = clones.get(attachTo.id) ?? [];
if (!foundFirstParent) {
foundFirstParent = true;
node.setParent(parent, attachTo.input);
} else {
cloneParents.unshift(parent);
}
for (const extraParent of cloneParents) {
const clonedNode = new SerializableAppNode(spec);
clonedNode.setParent(extraParent, attachTo.input);
clones.set(
spec.id,
clones.get(spec.id)?.concat(clonedNode) ?? [clonedNode],
);
}
}
}
if (!foundFirstParent) {
orphans.push(node);
}
} else {
let attachTo = spec.attachTo;
if (!isValidAttachmentPoint(attachTo, nodes)) {
attachTo =
redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
@@ -350,4 +350,84 @@ describe('createSpecializedApp', () => {
expect(screen.getByText('link: /test')).toBeInTheDocument();
});
it('should support multiple attachment points', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
createSpecializedApp({
features: [
createFrontendPlugin({
id: 'test',
extensions: [
createExtension({
name: 'root',
attachTo: { id: 'root', input: 'app' },
inputs: {
children: createExtensionInput([
coreExtensionData.reactElement,
]),
},
output: [coreExtensionData.reactElement],
factory: ({ apis }) => {
appTreeApi = apis.get(appTreeApiRef);
return [coreExtensionData.reactElement(<div />)];
},
}),
createExtension({
name: 'a',
attachTo: { id: 'test/root', input: 'children' },
inputs: {
children: createExtensionInput([
coreExtensionData.reactElement,
]),
},
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div />)],
}),
createExtension({
name: 'b',
attachTo: { id: 'test/root', input: 'children' },
inputs: {
children: createExtensionInput([
coreExtensionData.reactElement,
]),
},
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div />)],
}),
createExtension({
name: 'cloned',
attachTo: [
{ id: 'test/a', input: 'children' },
{ id: 'test/b', input: 'children' },
],
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div />)],
}),
],
}),
],
});
expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(`
"<root out=[core.reactElement]>
app [
<test/root out=[core.reactElement]>
children [
<test/a out=[core.reactElement]>
children [
<test/cloned out=[core.reactElement] />
]
</test/a>
<test/b out=[core.reactElement]>
children [
<test/cloned out=[core.reactElement] />
]
</test/b>
]
</test/root>
]
</root>"
`);
});
});
@@ -18,6 +18,7 @@ import {
AnyExtensionDataRef,
ApiHolder,
AppNode,
ExtensionAttachToSpec,
ExtensionDataValue,
ExtensionDefinition,
ExtensionDefinitionParameters,
@@ -35,7 +36,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: { id: string; input: string };
readonly attachTo: ExtensionAttachToSpec;
readonly disabled: boolean;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: {
@@ -66,7 +67,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: { id: string; input: string };
readonly attachTo: ExtensionAttachToSpec;
readonly disabled: boolean;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: {
+18 -28
View File
@@ -227,10 +227,7 @@ export interface AppNodeInstance {
// @public
export interface AppNodeSpec {
// (undocumented)
readonly attachTo: {
id: string;
input: string;
};
readonly attachTo: ExtensionAttachToSpec;
// (undocumented)
readonly config?: unknown;
// (undocumented)
@@ -593,10 +590,7 @@ export type CreateExtensionBlueprintOptions<
},
> = {
kind: TKind;
attachTo: {
id: string;
input: string;
};
attachTo: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -680,10 +674,7 @@ export type CreateExtensionOptions<
> = {
kind?: TKind;
name?: TName;
attachTo: {
id: string;
input: string;
};
attachTo: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -813,10 +804,7 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
// (undocumented)
$$type: '@backstage/Extension';
// (undocumented)
readonly attachTo: {
id: string;
input: string;
};
readonly attachTo: ExtensionAttachToSpec;
// (undocumented)
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
// (undocumented)
@@ -825,6 +813,17 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
readonly id: string;
}
// @public (undocumented)
export type ExtensionAttachToSpec =
| {
id: string;
input: string;
}
| Array<{
id: string;
input: string;
}>;
// @public (undocumented)
export interface ExtensionBlueprint<
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
@@ -834,10 +833,7 @@ export interface ExtensionBlueprint<
// (undocumented)
make<TNewName extends string | undefined>(args: {
name?: TNewName;
attachTo?: {
id: string;
input: string;
};
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
params: T['params'];
}): ExtensionDefinition<{
@@ -867,10 +863,7 @@ export interface ExtensionBlueprint<
},
>(args: {
name?: TNewName;
attachTo?: {
id: string;
input: string;
};
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -1057,10 +1050,7 @@ export type ExtensionDefinition<
>(
args: Expand<
{
attachTo?: {
id: string;
input: string;
};
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -15,7 +15,12 @@
*/
import { createApiRef } from '@backstage/core-plugin-api';
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
import {
FrontendPlugin,
Extension,
ExtensionDataRef,
ExtensionAttachToSpec,
} from '../../wiring';
/**
* The specification for this {@link AppNode} in the {@link AppTree}.
@@ -28,7 +33,7 @@ import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
*/
export interface AppNodeSpec {
readonly id: string;
readonly attachTo: { id: string; input: string };
readonly attachTo: ExtensionAttachToSpec;
readonly extension: Extension<unknown, unknown>;
readonly disabled: boolean;
readonly config?: unknown;
@@ -202,6 +202,20 @@ describe('createExtension', () => {
});
});
it('should create an extension with multiple attachment points', () => {
const extension = createExtension({
attachTo: [
{ id: 'root', input: 'default' },
{ id: 'other', input: 'default' },
],
output: [stringDataRef, numberDataRef.optional()],
factory: () => [stringDataRef('bar')],
});
expect(String(extension)).toBe(
'ExtensionDefinition{attachTo=root@default+other@default}',
);
});
it('should create an extension with input', () => {
const extension = createExtension({
attachTo: { id: 'root', input: 'default' },
@@ -112,6 +112,11 @@ export type VerifyExtensionFactoryOutput<
>}`
: never;
/** @public */
export type ExtensionAttachToSpec =
| { id: string; input: string }
| Array<{ id: string; input: string }>;
/** @public */
export type CreateExtensionOptions<
TKind extends string | undefined,
@@ -128,7 +133,7 @@ export type CreateExtensionOptions<
> = {
kind?: TKind;
name?: TName;
attachTo: { id: string; input: string };
attachTo: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -183,7 +188,7 @@ export type ExtensionDefinition<
>(
args: Expand<
{
attachTo?: { id: string; input: string };
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -338,7 +343,12 @@ export function createExtension<
if (options.name) {
parts.push(`name=${options.name}`);
}
parts.push(`attachTo=${options.attachTo.id}@${options.attachTo.input}`);
parts.push(
`attachTo=${[options.attachTo]
.flat()
.map(a => `${a.id}@${a.input}`)
.join('+')}`,
);
return `ExtensionDefinition{${parts.join(',')}}`;
},
override(overrideOptions) {
@@ -87,7 +87,11 @@ describe('createExtensionBlueprint', () => {
it('should allow creation of extension blueprints with a generator', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
// Try multiple attachment points for this one
attachTo: [
{ id: 'test-1', input: 'default' },
{ id: 'test-2', input: 'default' },
],
output: [coreExtensionData.reactElement],
*factory(params: { text: string }) {
yield coreExtensionData.reactElement(<h1>{params.text}</h1>);
@@ -103,10 +107,10 @@ describe('createExtensionBlueprint', () => {
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
attachTo: {
id: 'test',
input: 'default',
},
attachTo: [
{ id: 'test-1', input: 'default' },
{ id: 'test-2', input: 'default' },
],
configSchema: undefined,
disabled: false,
inputs: {},
@@ -17,6 +17,7 @@
import { ApiHolder, AppNode } from '../apis';
import { Expand } from '@backstage/types';
import {
ExtensionAttachToSpec,
ExtensionDefinition,
ResolvedExtensionInputs,
VerifyExtensionFactoryOutput,
@@ -57,7 +58,7 @@ export type CreateExtensionBlueprintOptions<
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> = {
kind: TKind;
attachTo: { id: string; input: string };
attachTo: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -107,7 +108,7 @@ export interface ExtensionBlueprint<
make<TNewName extends string | undefined>(args: {
name?: TNewName;
attachTo?: { id: string; input: string };
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
params: T['params'];
}): ExtensionDefinition<{
@@ -141,7 +142,7 @@ export interface ExtensionBlueprint<
},
>(args: {
name?: TNewName;
attachTo?: { id: string; input: string };
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -19,6 +19,7 @@ export {
createExtension,
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionAttachToSpec,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
@@ -16,6 +16,7 @@
import { ApiHolder, AppNode } from '../apis';
import {
ExtensionAttachToSpec,
ExtensionDefinition,
ExtensionDefinitionParameters,
ResolvedExtensionInputs,
@@ -32,7 +33,7 @@ import { OpaqueExtensionDefinition } from '@internal/frontend';
export interface Extension<TConfig, TConfigInput = TConfig> {
$$type: '@backstage/Extension';
readonly id: string;
readonly attachTo: { id: string; input: string };
readonly attachTo: ExtensionAttachToSpec;
readonly disabled: boolean;
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
}