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,