frontend-plugin-api: runtime attachTo by reference

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-03 11:16:41 +01:00
parent 7c6a66dd9f
commit 8b84f39946
13 changed files with 496 additions and 21 deletions
@@ -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=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
const overrdeExtension = extension.override({
attachTo: [
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
});
expect(String(overrdeExtension)).toBe(
'ExtensionDefinition{attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
});
it('should create an extension with input', () => {
const extension = createExtension({
attachTo: { id: 'root', input: 'default' },
@@ -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 '<detached-input>';
}
let id = '<plugin>';
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 = '<plugin>';
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:
@@ -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<string>().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=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/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:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
);
expect(
String(
blueprint.makeWithOverrides({
attachTo: {
relative: { kind: 'page', name: 'index' },
input: 'tabs',
},
factory: orig => orig({}),
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/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=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
expect(
String(
blueprint.make({
attachTo: [
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
params: {},
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
expect(
String(
blueprint.makeWithOverrides({
attachTo: parent4.inputs.otherTabs,
factory: orig => orig({}),
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@otherTabs}',
);
});
});
describe('with advanced parameter types', () => {
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
@@ -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<UExtensionData>;
config: TConfig;
replaces?: Array<{ id: string; input: string }>;
readonly $$type: '@backstage/ExtensionInput';
readonly extensionData: Array<UExtensionData>;
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();
}
@@ -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): {},
}
`);
});
@@ -20,6 +20,7 @@ export {
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionDefinitionAttachTo,
type ExtensionInputRef,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
@@ -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),
};
@@ -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<ExtensionDefinitionAttachTo, Array<any>>,
): { 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<T['config'], T['configInput']> {
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,