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
@@ -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,