diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts index 80d478646f..a8e63f82e0 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts @@ -137,6 +137,60 @@ 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' }, + ] as any, + }, + { + ...baseSpec, + id: 'd', + attachTo: [ + { id: 'b', input: 'x' }, + { id: 'c', input: 'x' }, + ] as any, + }, + ], + collector, + ); + + expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']); + + expect(String(tree.root)).toMatchInlineSnapshot(` + " + x [ + + 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', diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.ts b/packages/frontend-app-api/src/tree/resolveAppTree.ts index bdd9be0fbf..c2054c4bac 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts @@ -153,6 +153,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; @@ -163,6 +164,46 @@ 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 if (Array.isArray(spec.attachTo)) { + // eslint-disable-next-line no-console + console.warn( + `Extension '${spec.id}' is using multiple attachment points which is deprecated and will be removed in a future release. ` + + `Use a Utility API instead to share functionality across multiple locations. ` + + `See https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions for migration guidance.`, + ); + 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)) { diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 42611e78ca..4d3eeece2c 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -488,36 +488,40 @@ export function createExtension< if (options.name) { parts.push(`name=${options.name}`); } - const a = options.attachTo; - let attachTo: string; - if (OpaqueExtensionInput.isType(a)) { - const { context } = OpaqueExtensionInput.toInternal(a); - if (!context) { - attachTo = ''; - } else { - let id = ''; - if (context?.kind) { - id = `${context?.kind}:${id}`; + const attachTo = [options.attachTo] + .flat() + .map(aAny => { + const a = aAny as ExtensionDefinitionAttachTo; + if (OpaqueExtensionInput.isType(a)) { + const { context } = OpaqueExtensionInput.toInternal(a); + if (!context) { + return ''; + } + let id = ''; + if (context?.kind) { + id = `${context?.kind}:${id}`; + } + if (context?.name) { + id = `${id}/${context?.name}`; + } + return `${id}@${context.input}`; } - if (context?.name) { - id = `${id}/${context?.name}`; + if ('relative' in a && a.relative) { + let id = ''; + if (a.relative.kind) { + id = `${a.relative.kind}:${id}`; + } + if (a.relative.name) { + id = `${id}/${a.relative.name}`; + } + return `${id}@${a.input}`; } - attachTo = `${id}@${context.input}`; - } - } else if ('relative' in a && a.relative) { - let id = ''; - if (a.relative.kind) { - id = `${a.relative.kind}:${id}`; - } - if (a.relative.name) { - id = `${id}/${a.relative.name}`; - } - attachTo = `${id}@${a.input}`; - } else if ('id' in a) { - attachTo = `${a.id}@${a.input}`; - } else { - throw new Error('Invalid attachment point specification'); - } + if ('id' in a) { + return `${a.id}@${a.input}`; + } + throw new Error('Invalid attachment point specification'); + }) + .join('+'); parts.push(`attachTo=${attachTo}`); return `ExtensionDefinition{${parts.join(',')}}`; }, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index 79275ab03c..e080ab1f64 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -139,6 +139,43 @@ describe('resolveExtensionDefinition', () => { id: 'test', input: 'children', }); + + // Test for backward compatibility - runtime still supports multiple attachment points + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: [ + baseInpuf.withContext?.({ + kind: 'k1', + input: 'children', + }), + baseInpuf.withContext?.({ + kind: 'k2', + input: 'children', + }), + baseInpuf.withContext?.({ + kind: 'k3', + input: 'children', + }), + ] as any, + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual([ + { + id: 'k1:test', + input: 'children', + }, + { + id: 'k2:test', + input: 'children', + }, + { + id: 'k3:test', + input: 'children', + }, + ]); }); }); diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index a314983b25..70a0bd7837 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -141,35 +141,45 @@ function resolveExtensionId( } function resolveAttachTo( - attachTo: ExtensionDefinitionAttachTo, + attachTo: ExtensionDefinitionAttachTo | ExtensionDefinitionAttachTo[], namespace?: string, -): ExtensionAttachTo { - if (OpaqueExtensionInput.isType(attachTo)) { - const { context } = OpaqueExtensionInput.toInternal(attachTo); - if (!context) { - throw new Error( - 'Invalid input object without a parent extension used as attachment point', - ); +): ExtensionAttachTo | ExtensionAttachTo[] { + const resolveSpec = ( + spec: ExtensionDefinitionAttachTo, + ): { id: string; input: string } => { + if (OpaqueExtensionInput.isType(spec)) { + const { context } = OpaqueExtensionInput.toInternal(spec); + if (!context) { + throw new Error( + 'Invalid input object without a parent extension used as attachment point', + ); + } + return { + id: resolveExtensionId(context.kind, namespace, context.name), + input: context.input, + }; } - return { - id: resolveExtensionId(context.kind, namespace, context.name), - input: context.input, - }; + if ('relative' in spec && spec.relative) { + return { + id: resolveExtensionId( + spec.relative.kind, + namespace, + spec.relative.name, + ), + input: spec.input, + }; + } + if ('id' in spec) { + return { id: spec.id, input: spec.input }; + } + throw new Error('Invalid attachment point specification'); + }; + + if (Array.isArray(attachTo)) { + return attachTo.map(resolveSpec); } - if ('relative' in attachTo && attachTo.relative) { - return { - id: resolveExtensionId( - attachTo.relative.kind, - namespace, - attachTo.relative.name, - ), - input: attachTo.input, - }; - } - if ('id' in attachTo) { - return { id: attachTo.id, input: attachTo.input }; - } - throw new Error('Invalid attachment point specification'); + + return resolveSpec(attachTo); } /** @internal */ @@ -195,7 +205,7 @@ export function resolveExtensionDefinition< return { ...rest, - attachTo: resolveAttachTo(attachTo, namespace), + attachTo: resolveAttachTo(attachTo, namespace) as ExtensionAttachTo, $$type: '@backstage/Extension', version: internalDefinition.version, id,