From 8b84f399463fae5722cdc6a8b288e282b68b3df3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 3 Nov 2025 11:16:41 +0100 Subject: [PATCH] frontend-plugin-api: runtime attachTo by reference Signed-off-by: Patrik Oldsberg --- .../architecture/20-extensions.md | 49 +++++++ .../src/tree/resolveAppNodeSpecs.test.ts | 1 + .../src/wiring/InternalExtensionInput.ts | 36 ++++++ .../frontend-internal/src/wiring/index.ts | 6 +- packages/frontend-plugin-api/report.api.md | 20 ++- .../src/wiring/createExtension.test.ts | 58 ++++++++- .../src/wiring/createExtension.ts | 77 ++++++++++- .../wiring/createExtensionBlueprint.test.tsx | 122 +++++++++++++++++- .../src/wiring/createExtensionInput.ts | 29 +++-- .../src/wiring/createFrontendPlugin.test.ts | 2 + .../frontend-plugin-api/src/wiring/index.ts | 1 + .../wiring/resolveExtensionDefinition.test.ts | 81 ++++++++++++ .../src/wiring/resolveExtensionDefinition.ts | 35 ++++- 13 files changed, 496 insertions(+), 21 deletions(-) create mode 100644 packages/frontend-internal/src/wiring/InternalExtensionInput.ts diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 21f7a3bb7e..d2006a3cd4 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -390,3 +390,52 @@ const childExtension = createExtension({ }, }); ``` + +## Direct extension input references + +Building on the relative attachment point concept, you can also reference extension inputs directly via the `inputs` property of an extension definition. This provides a more convenient and type-safe way to attach child extensions, especially when working with blueprints within a plugin. + +Each extension definition exposes an `inputs` property that contains references to all of its defined inputs. These references can be passed directly to the `attachTo` option when creating child extensions: + +```tsx +// Create a parent extension using a blueprint +const page = PageBlueprint.make({ + params: { + path: '/example', + loader: () => import('./ExamplePage'), + }, +}); + +// Create a child extension that attaches to the parent's input +const widget = CardBlueprint.make({ + attachTo: page.inputs.children, // Direct reference to the input + params: { + title: 'Example Widget', + content:
Widget Content
, + }, +}); +``` + +This approach is functionally equivalent to using relative attachment points, but offers several advantages: + +- **Type safety**: The TypeScript compiler will verify that the referenced input exists and is spelled correctly +- **IDE support**: Your editor can provide autocomplete suggestions for available inputs +- **Clearer intent**: The code explicitly shows the relationship between parent and child extensions + +The direct input reference syntax works with both single attachment points and arrays of attachment points, and can be mixed with other attachment point formats: + +```tsx +// Attach to multiple parents using a mix of styles +const multiAttachChild = ExampleBlueprint.make({ + attachTo: [ + page.inputs.children, // Direct reference + { relative: { kind: 'sidebar' }, input: 'items' }, // Relative + { id: 'app/nav', input: 'items' }, // Absolute ID + ], + params: { + /* ... */ + }, +}); +``` + +Under the hood, input references are resolved in the same way as relative attachment points, using the extension's kind, namespace, and name to construct the final attachment target. diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 2eacd0ae67..59abb6a0eb 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -61,6 +61,7 @@ function makeExtDef( name, attachTo: { id: attachId, input: 'default' }, disabled: status === 'disabled', + inputs: {}, override: () => ({} as ExtensionDefinition), } as ExtensionDefinition; } diff --git a/packages/frontend-internal/src/wiring/InternalExtensionInput.ts b/packages/frontend-internal/src/wiring/InternalExtensionInput.ts new file mode 100644 index 0000000000..b816890f1a --- /dev/null +++ b/packages/frontend-internal/src/wiring/InternalExtensionInput.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ExtensionInput } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export type ExtensionInputContext = { + input: string; + kind?: string; + name?: string; +}; + +export const OpaqueExtensionInput = OpaqueType.create<{ + public: ExtensionInput; + versions: { + readonly version: undefined; + readonly context?: ExtensionInputContext; + withContext(context: ExtensionInputContext): ExtensionInput; + }; +}>({ + type: '@backstage/ExtensionInput', + versions: [undefined], +}); diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index b61294cb1f..1efcc7d16d 100644 --- a/packages/frontend-internal/src/wiring/index.ts +++ b/packages/frontend-internal/src/wiring/index.ts @@ -15,6 +15,10 @@ */ export { createExtensionDataContainer } from './createExtensionDataContainer'; -export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef'; export { OpaqueExtensionDefinition } from './InternalExtensionDefinition'; +export { + OpaqueExtensionInput, + type ExtensionInputContext, +} from './InternalExtensionInput'; export { OpaqueFrontendPlugin } from './InternalFrontendPlugin'; +export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c26e9a99c1..176d2e14bd 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -8,7 +8,6 @@ import { alertApiRef } from '@backstage/core-plugin-api'; import { AlertMessage } from '@backstage/core-plugin-api'; import { AnyApiFactory } from '@backstage/core-plugin-api'; import { AnyApiRef } from '@backstage/core-plugin-api'; -import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-plugin-api'; import { ApiFactory } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; @@ -385,7 +384,7 @@ export const coreExtensionData: { >; routePath: ConfigurableExtensionDataRef; routeRef: ConfigurableExtensionDataRef< - RouteRef, + RouteRef, 'core.routing.ref', {} >; @@ -1076,6 +1075,9 @@ export type ExtensionDefinition< > = { $$type: '@backstage/ExtensionDefinition'; readonly T: T; + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInputRef; + }; override< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; @@ -1186,6 +1188,7 @@ export type ExtensionDefinitionAttachTo = input: string; id?: never; } + | ExtensionInputRef | Array< | { id: string; @@ -1200,6 +1203,7 @@ export type ExtensionDefinitionAttachTo = input: string; id?: never; } + | ExtensionInputRef >; // @public (undocumented) @@ -1261,6 +1265,18 @@ export interface ExtensionInput< }>; } +// @public +export interface ExtensionInputRef { + // (undocumented) + $$type: '@backstage/ExtensionInputRef'; + // (undocumented) + input: string; + // (undocumented) + kind?: string; + // (undocumented) + name?: string; +} + // @public export interface ExternalRouteRef< TParams extends AnyRouteRefParams = AnyRouteRefParams, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 852fb09a97..f914412cfa 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -216,7 +216,7 @@ describe('createExtension', () => { ); }); - it('should create an extension with a relative attachment point', () => { + it('should create an extension with relative attachment points', () => { const extension = createExtension({ attachTo: [ { relative: {}, input: 'tabs' }, @@ -232,6 +232,62 @@ describe('createExtension', () => { ); }); + it('should create an extension with relative attachment points by reference', () => { + const baseOpts = { + attachTo: { id: 'root', input: 'children' }, + inputs: { + tabs: createExtensionInput([stringDataRef]), + }, + output: [], + factory: () => [], + }; + const parent1 = createExtension({ + ...baseOpts, + }); + const parent2 = createExtension({ + ...baseOpts, + kind: 'page', + }); + const parent3 = createExtension({ + ...baseOpts, + name: 'index', + }); + const parent4 = createExtension({ + ...baseOpts, + inputs: {}, + kind: 'page', + name: 'index', + }).override({ + inputs: { + otherTabs: createExtensionInput([stringDataRef]), + }, + factory: () => [], + }); + const extension = createExtension({ + attachTo: [ + parent1.inputs.tabs, + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + output: [stringDataRef], + factory: () => [stringDataRef('bar')], + }); + expect(String(extension)).toBe( + 'ExtensionDefinition{attachTo=@tabs+page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + const overrdeExtension = extension.override({ + attachTo: [ + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + }); + expect(String(overrdeExtension)).toBe( + 'ExtensionDefinition{attachTo=page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + }); + 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 fb9aa614b9..31876c9b6b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -20,7 +20,10 @@ import { ResolvedInputValueOverrides, resolveInputOverrides, } from './resolveInputOverrides'; -import { createExtensionDataContainer } from '@internal/frontend'; +import { + createExtensionDataContainer, + OpaqueExtensionInput, +} from '@internal/frontend'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import { z } from 'zod'; @@ -119,10 +122,11 @@ export type VerifyExtensionFactoryOutput< * * A standard attachment point declaration will specify the ID of the parent extension, as well as the name of the input to attach to. * - * There are two more advanced forms that are available for more complex use-cases: + * There are three more advanced forms that are available for more complex use-cases: * * 1. Relative attachment points: using the `relative` property instead of `id`, the attachment point is resolved relative to the current plugin. - * 2. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once. + * 2. Extension input references: using a reference obtained from another extension's `inputs` property. + * 3. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once. * * @example * ```ts @@ -132,6 +136,10 @@ export type VerifyExtensionFactoryOutput< * // Attach to an extension in the same plugin by kind * { relative: { kind: 'page' }, input: 'actions' } * + * // Attach to a specific input of another extension + * const page = ParentBlueprint.make({ ... }); + * const child = ChildBlueprint.make({ attachTo: page.inputs.children }); + * * // Attach to multiple parents at once * [ * { id: 'page/home', input: 'widgets' }, @@ -144,6 +152,7 @@ export type VerifyExtensionFactoryOutput< export type ExtensionDefinitionAttachTo = | { id: string; input: string; relative?: never } | { relative: { kind?: string; name?: string }; input: string; id?: never } + | ExtensionInput | Array< | { id: string; input: string; relative?: never } | { @@ -151,6 +160,7 @@ export type ExtensionDefinitionAttachTo = input: string; id?: never; } + | ExtensionInput >; /** @public */ @@ -213,6 +223,13 @@ export type ExtensionDefinition< $$type: '@backstage/ExtensionDefinition'; readonly T: T; + /** + * References to the inputs of this extension, which can be used to attach child extensions. + */ + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInput; + }; + override< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; @@ -306,6 +323,30 @@ export type ExtensionDefinition< }>; }; +/** + * @internal + */ +function bindInputs( + inputs: { [inputName in string]: ExtensionInput } | undefined, + kind?: string, + name?: string, +) { + if (!inputs) { + return {}; + } + + return Object.fromEntries( + Object.entries(inputs).map(([inputName, input]) => [ + inputName, + OpaqueExtensionInput.toInternal(input).withContext({ + kind, + name, + input: inputName, + }), + ]), + ); +} + /** * Creates a new extension definition for installation in a Backstage app. * @@ -419,7 +460,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, - inputs: options.inputs ?? {}, + inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, factory: options.factory, @@ -434,6 +475,20 @@ export function createExtension< const attachTo = [options.attachTo] .flat() .map(a => { + 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 ('relative' in a && a.relative) { let id = ''; if (a.relative.kind) { @@ -444,7 +499,10 @@ export function createExtension< } return `${id}@${a.input}`; } - return `${a.id}@${a.input}`; + if ('id' in a) { + return `${a.id}@${a.input}`; + } + throw new Error('Invalid attachment point specification'); }) .join('+'); parts.push(`attachTo=${attachTo}`); @@ -477,7 +535,14 @@ export function createExtension< name: options.name, attachTo: overrideOptions.attachTo ?? options.attachTo, disabled: overrideOptions.disabled ?? options.disabled, - inputs: { ...overrideOptions.inputs, ...options.inputs }, + inputs: bindInputs( + { + ...(options.inputs ?? {}), + ...(overrideOptions.inputs ?? {}), + }, + options.kind, + options.name, + ), output: (overrideOptions.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index e4cc876ff0..05f40dec82 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -30,7 +30,7 @@ import { } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; -import { ExtensionDefinition } from './createExtension'; +import { createExtension, ExtensionDefinition } from './createExtension'; import { createExtensionDataContainer, OpaqueExtensionDefinition, @@ -1345,6 +1345,126 @@ describe('createExtensionBlueprint', () => { ).toThrow('Refused to override params and factory at the same time'); }); + describe('with relative attachment points', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + + it('should create an extension with relative attachment points', () => { + const blueprint = createExtensionBlueprint({ + kind: 'test', + attachTo: [ + { relative: {}, input: 'tabs' }, + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + output: [dataRef], + factory: () => [dataRef('bar')], + }); + + expect(String(blueprint.make({ params: {} }))).toBe( + 'ExtensionDefinition{kind=test,attachTo=@tabs+page:@tabs+/index@tabs+page:/index@tabs}', + ); + expect( + String( + blueprint.make({ + attachTo: [ + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + params: {}, + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:@tabs+/index@tabs+page:/index@tabs}', + ); + expect( + String( + blueprint.makeWithOverrides({ + attachTo: { + relative: { kind: 'page', name: 'index' }, + input: 'tabs', + }, + factory: orig => orig({}), + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:/index@tabs}', + ); + }); + + it('should create an extension with relative attachment points by reference', () => { + const baseOpts = { + attachTo: { id: 'root', input: 'children' }, + inputs: { + tabs: createExtensionInput([dataRef]), + }, + output: [], + factory: () => [], + }; + const parent1 = createExtension({ + ...baseOpts, + }); + const parent2 = createExtension({ + ...baseOpts, + kind: 'page', + }); + const parent3 = createExtension({ + ...baseOpts, + name: 'index', + }); + const parent4 = createExtension({ + ...baseOpts, + inputs: {}, + kind: 'page', + name: 'index', + }).override({ + inputs: { + otherTabs: createExtensionInput([dataRef]), + }, + factory: () => [], + }); + const blueprint = createExtensionBlueprint({ + kind: 'test', + attachTo: [ + parent1.inputs.tabs, + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + output: [dataRef], + factory: () => [dataRef('bar')], + }); + expect(String(blueprint.make({ params: {} }))).toBe( + 'ExtensionDefinition{kind=test,attachTo=@tabs+page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + expect( + String( + blueprint.make({ + attachTo: [ + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + params: {}, + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + expect( + String( + blueprint.makeWithOverrides({ + attachTo: parent4.inputs.otherTabs, + factory: orig => orig({}), + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:/index@otherTabs}', + ); + }); + }); + describe('with advanced parameter types', () => { const testDataRef = createExtensionDataRef().with({ id: 'test' }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts index 24e14c45b2..57040c8adf 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + ExtensionInputContext, + OpaqueExtensionInput, +} from '@internal/frontend'; import { ExtensionDataRef } from './createExtensionDataRef'; /** @public */ @@ -28,10 +32,10 @@ export interface ExtensionInput< optional: boolean; }, > { - $$type: '@backstage/ExtensionInput'; - extensionData: Array; - config: TConfig; - replaces?: Array<{ id: string; input: string }>; + readonly $$type: '@backstage/ExtensionInput'; + readonly extensionData: Array; + readonly config: TConfig; + readonly replaces?: Array<{ id: string; input: string }>; } /** @public */ @@ -68,8 +72,7 @@ export function createExtensionInput< } } } - return { - $$type: '@backstage/ExtensionInput', + const baseOptions = { extensionData, config: { singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true @@ -80,11 +83,21 @@ export function createExtensionInput< : false, }, replaces: config?.replaces, - } as ExtensionInput< + }; + + function createInstance(parent?: ExtensionInputContext): ExtensionInput< UExtensionData, { singleton: TConfig['singleton'] extends true ? true : false; optional: TConfig['optional'] extends true ? true : false; } - >; + > { + return OpaqueExtensionInput.createInstance(undefined, { + ...baseOptions, + context: parent, + withContext: createInstance, + }); + } + + return createInstance(); } diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index a3226721a1..f739a1d514 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -170,6 +170,7 @@ describe('createFrontendPlugin', () => { "override": [Function], "toString": [Function], "version": "v2", + Symbol(@backstage/ExtensionDefinition/internalInputs): {}, } `); // @ts-expect-error @@ -358,6 +359,7 @@ describe('createFrontendPlugin', () => { "override": [Function], "toString": [Function], "version": "v2", + Symbol(@backstage/ExtensionDefinition/internalInputs): {}, } `); }); diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index d9baa201ab..da3b3bfca1 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -20,6 +20,7 @@ export { type ExtensionDefinition, type ExtensionDefinitionParameters, type ExtensionDefinitionAttachTo, + type ExtensionInputRef, type CreateExtensionOptions, type ResolvedExtensionInput, type ResolvedExtensionInputs, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index f63df8159f..4095973752 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -27,6 +27,7 @@ describe('resolveExtensionDefinition', () => { version: 'v2', attachTo: { id: '', input: '' }, disabled: false, + inputs: {}, override: () => ({} as ExtensionDefinition), }; @@ -77,6 +78,85 @@ describe('resolveExtensionDefinition', () => { { id: 'page:test/index', input: 'tabs' }, ]); }); + + it('should resolve extension input references', () => { + const resolved = resolveExtensionDefinition( + { + ...baseDef, + attachTo: { + $$type: '@backstage/ExtensionInputRef', + kind: 'parent', + name: 'example', + input: 'children', + }, + } as ExtensionDefinition, + { namespace: 'test' }, + ); + expect(resolved.attachTo).toEqual({ + id: 'parent:test/example', + input: 'children', + }); + }); + + it('should resolve extension input references without name', () => { + const resolved = resolveExtensionDefinition( + { + ...baseDef, + attachTo: { + $$type: '@backstage/ExtensionInputRef', + kind: 'parent', + input: 'children', + }, + } as ExtensionDefinition, + { namespace: 'test' }, + ); + expect(resolved.attachTo).toEqual({ + id: 'parent:test', + input: 'children', + }); + }); + + it('should resolve extension input references without kind', () => { + const resolved = resolveExtensionDefinition( + { + ...baseDef, + attachTo: { + $$type: '@backstage/ExtensionInputRef', + name: 'example', + input: 'children', + }, + } as ExtensionDefinition, + { namespace: 'test' }, + ); + expect(resolved.attachTo).toEqual({ + id: 'test/example', + input: 'children', + }); + }); + + it('should resolve array with mixed attachment types including input references', () => { + const resolved = resolveExtensionDefinition( + { + ...baseDef, + attachTo: [ + { id: 'page:home', input: 'widgets' }, + { relative: { kind: 'page' }, input: 'actions' }, + { + $$type: '@backstage/ExtensionInputRef', + kind: 'parent', + name: 'example', + input: 'children', + }, + ], + } as ExtensionDefinition, + { namespace: 'test' }, + ); + expect(resolved.attachTo).toEqual([ + { id: 'page:home', input: 'widgets' }, + { id: 'page:test', input: 'actions' }, + { id: 'parent:test/example', input: 'children' }, + ]); + }); }); describe('old resolveExtensionDefinition', () => { @@ -86,6 +166,7 @@ describe('old resolveExtensionDefinition', () => { version: 'v1', attachTo: { id: '', input: '' }, disabled: false, + inputs: {}, override: () => ({} as ExtensionDefinition), }; diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 5bed37588c..c500954be4 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -24,7 +24,10 @@ import { import { PortableSchema } from '../schema'; import { ExtensionInput } from './createExtensionInput'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; -import { OpaqueExtensionDefinition } from '@internal/frontend'; +import { + OpaqueExtensionDefinition, + OpaqueExtensionInput, +} from '@internal/frontend'; /** @public */ export type ExtensionAttachTo = @@ -152,6 +155,18 @@ function resolveAttachTo( const resolveSpec = ( spec: Exclude>, ): { 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, + }; + } if ('relative' in spec && spec.relative) { return { id: resolveExtensionId( @@ -162,7 +177,10 @@ function resolveAttachTo( input: spec.input, }; } - return { id: spec.id, 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)) { @@ -180,6 +198,19 @@ export function resolveExtensionDefinition< context?: { namespace?: string }, ): Extension { const internalDefinition = OpaqueExtensionDefinition.toInternal(definition); + + // Restore internal inputs if they were saved under a symbol + // This is needed because we override the inputs property with ExtensionInputRef objects + const internalInputsSymbol = Symbol.for( + '@backstage/ExtensionDefinition/internalInputs', + ); + if ((internalDefinition as any)[internalInputsSymbol]) { + // Restore the internal inputs from the symbol + (internalDefinition as any).inputs = (internalDefinition as any)[ + internalInputsSymbol + ]; + } + const { name, kind,