From f1efb47bb4f7ea1e28f38c3d731677291ce34b9f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 24 Jan 2025 16:14:50 +0100 Subject: [PATCH] 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; }