From f1efb47bb4f7ea1e28f38c3d731677291ce34b9f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 24 Jan 2025 16:14:50 +0100 Subject: [PATCH 1/3] frontend-plugin-api: add support for multiple attachment points Signed-off-by: Patrik Oldsberg --- .changeset/three-glasses-sell.md | 6 + .../src/tree/resolveAppTree.test.ts | 118 +++++++++++++++--- .../src/tree/resolveAppTree.ts | 28 ++++- .../src/wiring/createSpecializedApp.test.tsx | 80 ++++++++++++ .../src/wiring/InternalExtensionDefinition.ts | 5 +- packages/frontend-plugin-api/report.api.md | 46 +++---- .../src/apis/definitions/AppTreeApi.ts | 9 +- .../src/wiring/createExtension.test.ts | 14 +++ .../src/wiring/createExtension.ts | 16 ++- .../wiring/createExtensionBlueprint.test.tsx | 14 ++- .../src/wiring/createExtensionBlueprint.ts | 7 +- .../frontend-plugin-api/src/wiring/index.ts | 1 + .../src/wiring/resolveExtensionDefinition.ts | 3 +- 13 files changed, 281 insertions(+), 66 deletions(-) create mode 100644 .changeset/three-glasses-sell.md 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; } From 93a0f15c6e886b27d9957511636bee3eb2b7ef25 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 24 Jan 2025 16:28:08 +0100 Subject: [PATCH 2/3] frontend-plugin-api: enable recursive cloning of extensions Signed-off-by: Patrik Oldsberg --- .../src/tree/resolveAppTree.test.ts | 38 +++---------------- .../src/tree/resolveAppTree.ts | 14 ++++++- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts index 6d84720648..e93d311f8d 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts @@ -143,44 +143,16 @@ describe('buildAppTree', () => { 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 [ + + ] + ] diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.ts b/packages/frontend-app-api/src/tree/resolveAppTree.ts index f346dc50bf..dacd06407a 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts @@ -145,6 +145,7 @@ export function resolveAppTree( } const orphans = new Array(); + const clones = new Map>(); // 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; @@ -167,13 +168,22 @@ export function resolveAppTree( const parent = nodes.get(attachTo.id); if (parent) { + const cloneParents = clones.get(attachTo.id) ?? []; + 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`? + cloneParents.unshift(parent); + } + + for (const extraParent of cloneParents) { const clonedNode = new SerializableAppNode(spec); - clonedNode.setParent(parent, attachTo.input); + clonedNode.setParent(extraParent, attachTo.input); + clones.set( + spec.id, + clones.get(spec.id)?.concat(clonedNode) ?? [clonedNode], + ); } } } From f5790837cf4c75c344c1e7dee49b84531ec0b481 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 6 Feb 2025 14:22:09 +0100 Subject: [PATCH 3/3] docs/frontend-system: add docs for multiple parents Signed-off-by: Patrik Oldsberg --- .../architecture/20-extensions.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 02f4ba9d05..eae0405bda 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -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(
Hello World
)]; + }, +}); +```