frontend-plugin-api: add support for relative attachments

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-10-23 10:35:05 +02:00
parent 01476f00de
commit 7c6a66dd9f
11 changed files with 263 additions and 43 deletions
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/frontend-plugin-api': patch
---
Added support for plugin-relative `attachTo` declarations for extension definitions. This allows for the creation of extension and extension blueprints that attach to other extensions of a particular `kind` in the same plugin, rather than needing to provide the exact extension ID. This is particularly useful when wanting to provide extension blueprints with a built-in hierarchy where the extensions created from one blueprint attach to extensions created from the other blueprint, for example:
```ts
// kind: 'tabbed-page'
const parentPage = TabbedPageBlueprint.make({
params: {....}
})
// attachTo: { kind: 'tabbed-page', input: 'tabs' }
const child1 = TabContentBlueprint.make({
name: 'tab1',
params: {....}
})
```
@@ -352,3 +352,41 @@ const extension = createExtension({
},
});
```
## Relative attachment points
When creating an extension or an [extension blueprint](./23-extension-blueprints.md) you can specify an attachment point that is relative to the current plugin. This is particularly useful for groups of blueprints that are part of a common hierarchy, with extensions from one blueprint attaching to extensions from the other blueprint. For example, the following pair of extension definitions could be installed multiple times in different plugins, each creating their own hierarchy:
```tsx
// Parent extension with a fixed attachment point
const parentExtension = createExtension({
kind: 'section',
attachTo: [{ id: 'app/some-fixed-extension', input: 'children' }],
inputs: {
content: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return [
coreExtensionData.reactElement(
<section>
<h1>Section Title</h1>
{inputs.content.get(coreExtensionData.reactElement)}
</section>,
),
];
},
});
// Child extension with a relative attachment point
const childExtension = createExtension({
kind: 'section-content',
attachTo: [{ relative: { kind: 'section' }, input: 'content' }],
output: [coreExtensionData.reactElement],
factory() {
return [coreExtensionData.reactElement(<p>Section Content</p>)];
},
});
```
@@ -17,7 +17,7 @@
import {
ApiHolder,
AppNode,
ExtensionAttachToSpec,
ExtensionDefinitionAttachTo,
ExtensionDataValue,
ExtensionDataRef,
ExtensionDefinition,
@@ -36,7 +36,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: ExtensionAttachToSpec;
readonly attachTo: ExtensionDefinitionAttachTo;
readonly disabled: boolean;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: {
@@ -67,7 +67,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: ExtensionAttachToSpec;
readonly attachTo: ExtensionDefinitionAttachTo;
readonly disabled: boolean;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: { [inputName in string]: ExtensionInput };
+41 -7
View File
@@ -259,7 +259,7 @@ export interface AppNodeInstance {
// @public
export interface AppNodeSpec {
// (undocumented)
readonly attachTo: ExtensionAttachToSpec;
readonly attachTo: ExtensionAttachTo;
// (undocumented)
readonly config?: unknown;
// (undocumented)
@@ -510,7 +510,7 @@ export type CreateExtensionBlueprintOptions<
},
> = {
kind: TKind;
attachTo: ExtensionAttachToSpec;
attachTo: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -592,7 +592,7 @@ export type CreateExtensionOptions<
> = {
kind?: TKind;
name?: TName;
attachTo: ExtensionAttachToSpec;
attachTo: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -839,7 +839,7 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
}
// @public (undocumented)
export type ExtensionAttachToSpec =
export type ExtensionAttachTo =
| {
id: string;
input: string;
@@ -849,6 +849,9 @@ export type ExtensionAttachToSpec =
input: string;
}>;
// @public @deprecated (undocumented)
export type ExtensionAttachToSpec = ExtensionAttachTo;
// @public (undocumented)
export interface ExtensionBlueprint<
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
@@ -861,7 +864,7 @@ export interface ExtensionBlueprint<
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
>(args: {
name?: TName;
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
@@ -889,7 +892,7 @@ export interface ExtensionBlueprint<
},
>(args: {
name?: TName;
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -1086,7 +1089,7 @@ export type ExtensionDefinition<
>(
args: Expand<
{
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -1168,6 +1171,37 @@ export type ExtensionDefinition<
}>;
};
// @public
export type ExtensionDefinitionAttachTo =
| {
id: string;
input: string;
relative?: never;
}
| {
relative: {
kind?: string;
name?: string;
};
input: string;
id?: never;
}
| Array<
| {
id: string;
input: string;
relative?: never;
}
| {
relative: {
kind?: string;
name?: string;
};
input: string;
id?: never;
}
>;
// @public (undocumented)
export type ExtensionDefinitionParameters = {
kind?: string;
@@ -19,7 +19,7 @@ import {
FrontendPlugin,
Extension,
ExtensionDataRef,
ExtensionAttachToSpec,
ExtensionAttachTo,
} from '../../wiring';
/**
@@ -33,7 +33,7 @@ import {
*/
export interface AppNodeSpec {
readonly id: string;
readonly attachTo: ExtensionAttachToSpec;
readonly attachTo: ExtensionAttachTo;
readonly extension: Extension<unknown, unknown>;
readonly disabled: boolean;
readonly config?: unknown;
@@ -216,6 +216,22 @@ describe('createExtension', () => {
);
});
it('should create an extension with a relative attachment point', () => {
const extension = createExtension({
attachTo: [
{ relative: {}, input: 'tabs' },
{ relative: { kind: 'page' }, input: 'tabs' },
{ relative: { name: 'index' }, input: 'tabs' },
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
],
output: [stringDataRef],
factory: () => [stringDataRef('bar')],
});
expect(String(extension)).toBe(
'ExtensionDefinition{attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
);
});
it('should create an extension with input', () => {
const extension = createExtension({
attachTo: { id: 'root', input: 'default' },
@@ -112,10 +112,46 @@ export type VerifyExtensionFactoryOutput<
>}`
: never;
/** @public */
export type ExtensionAttachToSpec =
| { id: string; input: string }
| Array<{ id: string; input: string }>;
/**
* Specifies where an extension should attach in the extension tree.
*
* @remarks
*
* 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:
*
* 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.
*
* @example
* ```ts
* // Attach to a specific extension by full ID
* { id: 'app/routes', input: 'routes' }
*
* // Attach to an extension in the same plugin by kind
* { relative: { kind: 'page' }, input: 'actions' }
*
* // Attach to multiple parents at once
* [
* { id: 'page/home', input: 'widgets' },
* { relative: { kind: 'page' }, input: 'widgets' },
* ]
* ```
*
* @public
*/
export type ExtensionDefinitionAttachTo =
| { id: string; input: string; relative?: never }
| { relative: { kind?: string; name?: string }; input: string; id?: never }
| Array<
| { id: string; input: string; relative?: never }
| {
relative: { kind?: string; name?: string };
input: string;
id?: never;
}
>;
/** @public */
export type CreateExtensionOptions<
@@ -128,7 +164,7 @@ export type CreateExtensionOptions<
> = {
kind?: TKind;
name?: TName;
attachTo: ExtensionAttachToSpec;
attachTo: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -188,7 +224,7 @@ export type ExtensionDefinition<
>(
args: Expand<
{
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -395,12 +431,23 @@ export function createExtension<
if (options.name) {
parts.push(`name=${options.name}`);
}
parts.push(
`attachTo=${[options.attachTo]
.flat()
.map(a => `${a.id}@${a.input}`)
.join('+')}`,
);
const attachTo = [options.attachTo]
.flat()
.map(a => {
if ('relative' in a && a.relative) {
let id = '<plugin>';
if (a.relative.kind) {
id = `${a.relative.kind}:${id}`;
}
if (a.relative.name) {
id = `${id}/${a.relative.name}`;
}
return `${id}@${a.input}`;
}
return `${a.id}@${a.input}`;
})
.join('+');
parts.push(`attachTo=${attachTo}`);
return `ExtensionDefinition{${parts.join(',')}}`;
},
override(overrideOptions) {
@@ -18,7 +18,7 @@ import { ApiHolder, AppNode } from '../apis';
import { Expand } from '@backstage/types';
import { OpaqueType } from '@internal/opaque';
import {
ExtensionAttachToSpec,
ExtensionDefinitionAttachTo,
ExtensionDefinition,
ResolvedExtensionInputs,
VerifyExtensionFactoryOutput,
@@ -109,7 +109,7 @@ export type CreateExtensionBlueprintOptions<
TDataRefs extends { [name in string]: ExtensionDataRef },
> = {
kind: TKind;
attachTo: ExtensionAttachToSpec;
attachTo: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -214,7 +214,7 @@ export interface ExtensionBlueprint<
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
>(args: {
name?: TName;
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
@@ -247,7 +247,7 @@ export interface ExtensionBlueprint<
TExtraInputs extends { [inputName in string]: ExtensionInput },
>(args: {
name?: TName;
attachTo?: ExtensionAttachToSpec;
attachTo?: ExtensionDefinitionAttachTo;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -19,7 +19,7 @@ export {
createExtension,
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionAttachToSpec,
type ExtensionDefinitionAttachTo,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
@@ -54,7 +54,11 @@ export {
type FrontendFeatureLoader,
type CreateFrontendFeatureLoaderOptions,
} from './createFrontendFeatureLoader';
export { type Extension } from './resolveExtensionDefinition';
export {
type Extension,
type ExtensionAttachTo,
type ExtensionAttachToSpec,
} from './resolveExtensionDefinition';
export {
type ExtensionDataContainer,
type FeatureFlagConfig,
@@ -60,6 +60,23 @@ describe('resolveExtensionDefinition', () => {
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined',
);
});
it('should resolve relative attachment points', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: [
{ relative: { kind: 'page' }, input: 'tabs' },
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
],
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual([
{ id: 'page:test', input: 'tabs' },
{ id: 'page:test/index', input: 'tabs' },
]);
});
});
describe('old resolveExtensionDefinition', () => {
@@ -16,7 +16,7 @@
import { ApiHolder, AppNode } from '../apis';
import {
ExtensionAttachToSpec,
ExtensionDefinitionAttachTo,
ExtensionDefinition,
ExtensionDefinitionParameters,
ResolvedExtensionInputs,
@@ -26,6 +26,17 @@ import { ExtensionInput } from './createExtensionInput';
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { OpaqueExtensionDefinition } from '@internal/frontend';
/** @public */
export type ExtensionAttachTo =
| { id: string; input: string }
| Array<{ id: string; input: string }>;
/**
* @deprecated Use {@link ExtensionAttachTo} instead.
* @public
*/
export type ExtensionAttachToSpec = ExtensionAttachTo;
/** @public */
export interface Extension<TConfig, TConfigInput = TConfig> {
$$type: '@backstage/Extension';
@@ -118,6 +129,49 @@ export type ResolveExtensionId<
: never
: never;
function resolveExtensionId(
kind?: string,
namespace?: string,
name?: string,
): string {
const namePart =
name && namespace ? `${namespace}/${name}` : namespace || name;
if (!namePart) {
throw new Error(
`Extension must declare an explicit namespace or name as it could not be resolved from context, kind=${kind} namespace=${namespace} name=${name}`,
);
}
return kind ? `${kind}:${namePart}` : namePart;
}
function resolveAttachTo(
attachTo: ExtensionDefinitionAttachTo,
namespace?: string,
): ExtensionAttachToSpec {
const resolveSpec = (
spec: Exclude<ExtensionDefinitionAttachTo, Array<any>>,
): { id: string; input: string } => {
if ('relative' in spec && spec.relative) {
return {
id: resolveExtensionId(
spec.relative.kind,
namespace,
spec.relative.name,
),
input: spec.input,
};
}
return { id: spec.id, input: spec.input };
};
if (Array.isArray(attachTo)) {
return attachTo.map(resolveSpec);
}
return resolveSpec(attachTo);
}
/** @internal */
export function resolveExtensionDefinition<
T extends ExtensionDefinitionParameters,
@@ -129,25 +183,18 @@ export function resolveExtensionDefinition<
const {
name,
kind,
namespace: _skip1,
namespace: internalNamespace,
override: _skip2,
attachTo,
...rest
} = internalDefinition;
const namespace = internalDefinition.namespace ?? context?.namespace;
const namePart =
name && namespace ? `${namespace}/${name}` : namespace || name;
if (!namePart) {
throw new Error(
`Extension must declare an explicit namespace or name as it could not be resolved from context, kind=${kind} namespace=${namespace} name=${name}`,
);
}
const id = kind ? `${kind}:${namePart}` : namePart;
const namespace = internalNamespace ?? context?.namespace;
const id = resolveExtensionId(kind, namespace, name);
return {
...rest,
attachTo: resolveAttachTo(attachTo, namespace),
$$type: '@backstage/Extension',
version: internalDefinition.version,
id,