frontend-plugin-api: add support for multiple attachment points

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-01-24 16:14:50 +01:00
parent fccbb7f354
commit f1efb47bb4
13 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.
@@ -119,6 +119,84 @@ 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(JSON.parse(JSON.stringify(tree.root))).toMatchInlineSnapshot(`
{
"attachments": {
"x": [
{
"attachments": {
"x": [
{
"id": "c",
},
{
"id": "d",
},
],
},
"id": "b",
},
{
"attachments": {
"x": [
{
"id": "d",
},
],
},
"id": "c",
},
],
},
"id": "a",
}
`);
expect(String(tree.root)).toMatchInlineSnapshot(`
"<a>
x [
<b>
x [
<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 +317,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', () => {
@@ -155,9 +155,33 @@ 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) {
if (!foundFirstParent) {
foundFirstParent = true;
node.setParent(parent, attachTo.input);
} else {
// TODO(Rugvip): Perhaps makes sense to keep track of these with a `clones` map, similar to `orphans`?
const clonedNode = new SerializableAppNode(spec);
clonedNode.setParent(parent, attachTo.input);
}
}
}
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>;
}