diff --git a/.changeset/three-glasses-sell.md b/.changeset/three-glasses-sell.md
new file mode 100644
index 0000000000..9714f6f184
--- /dev/null
+++ b/.changeset/three-glasses-sell.md
@@ -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.
diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
index 0517e943aa..6d84720648 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
@@ -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(`
+ "
+ x [
+
+ x [
+
+
+ ]
+
+
+ x [
+
+ ]
+
+ ]
+ "
+ `);
+
+ 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(`
- "
- test [
-
- ]
- "
- `);
+ "
+ test [
+
+ ]
+ "
+ `);
});
it('should not allow redirects for attachment points that already exist', () => {
diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.ts b/packages/frontend-app-api/src/tree/resolveAppTree.ts
index dc9985da30..f346dc50bf 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts
@@ -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;
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
index 6c5331a972..a1399d786a 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -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(
)];
+ },
+ }),
+ createExtension({
+ name: 'a',
+ attachTo: { id: 'test/root', input: 'children' },
+ inputs: {
+ children: createExtensionInput([
+ coreExtensionData.reactElement,
+ ]),
+ },
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement()],
+ }),
+ createExtension({
+ name: 'b',
+ attachTo: { id: 'test/root', input: 'children' },
+ inputs: {
+ children: createExtensionInput([
+ coreExtensionData.reactElement,
+ ]),
+ },
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement()],
+ }),
+ createExtension({
+ name: 'cloned',
+ attachTo: [
+ { id: 'test/a', input: 'children' },
+ { id: 'test/b', input: 'children' },
+ ],
+ output: [coreExtensionData.reactElement],
+ factory: () => [coreExtensionData.reactElement()],
+ }),
+ ],
+ }),
+ ],
+ });
+
+ expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(`
+ "
+ app [
+
+ children [
+
+ children [
+
+ ]
+
+
+ children [
+
+ ]
+
+ ]
+
+ ]
+ "
+ `);
+ });
});
diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
index 12027e0bb6..44dfb002ad 100644
--- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
+++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
@@ -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;
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;
readonly inputs: {
diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md
index 6202eeaaa2..13eb751361 100644
--- a/packages/frontend-plugin-api/report.api.md
+++ b/packages/frontend-plugin-api/report.api.md
@@ -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;
@@ -680,10 +674,7 @@ export type CreateExtensionOptions<
> = {
kind?: TKind;
name?: TName;
- attachTo: {
- id: string;
- input: string;
- };
+ attachTo: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TInputs;
output: Array;
@@ -813,10 +804,7 @@ export interface Extension {
// (undocumented)
$$type: '@backstage/Extension';
// (undocumented)
- readonly attachTo: {
- id: string;
- input: string;
- };
+ readonly attachTo: ExtensionAttachToSpec;
// (undocumented)
readonly configSchema?: PortableSchema;
// (undocumented)
@@ -825,6 +813,17 @@ export interface Extension {
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(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 &
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
index 6ced6706b9..c9d9896600 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
@@ -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;
readonly disabled: boolean;
readonly config?: unknown;
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
index 3eb6445fee..07a1bfcdb0 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts
@@ -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' },
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index baf0b31215..f93c9d21ce 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -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;
@@ -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) {
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx
index 16786f9757..c6dcf74e1c 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx
@@ -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({params.text}
);
@@ -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: {},
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
index 3d55b0bbe0..880caba967 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
@@ -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;
@@ -107,7 +108,7 @@ export interface ExtensionBlueprint<
make(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 &
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index ebbaf4429f..8c90eeef51 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -19,6 +19,7 @@ export {
createExtension,
type ExtensionDefinition,
type ExtensionDefinitionParameters,
+ type ExtensionAttachToSpec,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
index 1e8ac77a01..7e771c6c2c 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
@@ -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 {
$$type: '@backstage/Extension';
readonly id: string;
- readonly attachTo: { id: string; input: string };
+ readonly attachTo: ExtensionAttachToSpec;
readonly disabled: boolean;
readonly configSchema?: PortableSchema;
}